Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/apps/control-panels/hooks/useControlPanelsLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,8 @@ export function useControlPanelsLogic({
const metadata = await downloadAndApplyCloudSyncDomain(domain, {
username,
isAuthenticated,
}, {
applyMode: "replace",
});
syncStore.markDownloadSuccess(domain, metadata.updatedAt);
syncStore.updateRemoteMetadataForDomain(domain, metadata);
Expand Down
11 changes: 11 additions & 0 deletions src/hooks/useAutoCloudSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
getSyncSessionId,
uploadCloudSyncDomain,
} from "@/utils/cloudSync";
import {
describeActiveCloudSyncMutationSources,
shouldSkipAutoCloudSyncUpload,
} from "@/utils/cloudSyncMutationSource";
import {
CLOUD_SYNC_DOMAINS,
CLOUD_SYNC_REMOTE_APPLY_DOMAINS,
Expand Down Expand Up @@ -237,6 +241,13 @@ export function useAutoCloudSync() {
return;
}

if (shouldSkipAutoCloudSyncUpload()) {
console.log(
`[CloudSync] queueUpload(${domain}) ignored: non-user mutation (${describeActiveCloudSyncMutationSources()})`
);
return;
}

const suppressUntil = remoteApplySuppressUntilRef.current[domain];
if (Date.now() < suppressUntil) {
const remainingMs = suppressUntil - Date.now();
Expand Down
38 changes: 24 additions & 14 deletions src/stores/useFilesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ensureIndexedDBInitialized, STORES } from "@/utils/indexedDB";
import type { OsThemeId } from "@/themes/types";
import { getAppBasicInfoList } from "@/config/appRegistryData";
import { abortableFetch } from "@/utils/abortableFetch";
import { runWithCloudSyncMutationSource } from "@/utils/cloudSyncMutationSource";
import { useCloudSyncStore } from "@/stores/useCloudSyncStore";

// Define the structure for a file system item (metadata)
Expand Down Expand Up @@ -1374,26 +1375,35 @@ export const useFilesStore = create<FilesStoreState>()(
if (state.libraryState === "uninitialized") {
// For new users: initializeLibrary handles everything including
// creating directories and desktop shortcuts in proper order
Promise.resolve(state.initializeLibrary()).catch((err) =>
void runWithCloudSyncMutationSource(
"system-bootstrap",
() => state.initializeLibrary(),
"files-store:initializeLibrary"
).catch((err) =>
console.error("Files initialization failed on rehydrate", err)
);
} else {
// For existing users: sync root directories and ensure desktop shortcuts
// This handles cases where new apps are added in updates
// Also register default files for lazy loading (uses cached JSON)
Promise.all([
loadDefaultFiles().then((data) => {
registerFilesForLazyLoad(data.files, state.items);
}),
loadDefaultApplets().then((data) => {
registerFilesForLazyLoad(data.applets, state.items);
}),
state.syncRootDirectoriesFromDefaults().then(() => {
if (state.ensureDefaultDesktopShortcuts) {
return state.ensureDefaultDesktopShortcuts();
}
}),
]).catch(
void runWithCloudSyncMutationSource(
"system-bootstrap",
() =>
Promise.all([
loadDefaultFiles().then((data) => {
registerFilesForLazyLoad(data.files, state.items);
}),
loadDefaultApplets().then((data) => {
registerFilesForLazyLoad(data.applets, state.items);
}),
state.syncRootDirectoriesFromDefaults().then(() => {
if (state.ensureDefaultDesktopShortcuts) {
return state.ensureDefaultDesktopShortcuts();
}
}),
]),
"files-store:rehydrate"
).catch(
(err) =>
console.error(
"Files root directory sync failed on rehydrate",
Expand Down
7 changes: 6 additions & 1 deletion src/stores/useIpodStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { FuriganaSegment } from "@/utils/romanization";
import { getApiUrl } from "@/utils/platform";
import { getAppPublicOrigin } from "@/utils/runtimeConfig";
import { getCachedSongMetadata, listAllCachedSongMetadata } from "@/utils/songMetadataCache";
import { runWithCloudSyncMutationSource } from "@/utils/cloudSyncMutationSource";
import i18n from "@/lib/i18n";
import { useChatsStore } from "./useChatsStore";
import { abortableFetch } from "@/utils/abortableFetch";
Expand Down Expand Up @@ -1533,7 +1534,11 @@ export const useIpodStore = create<IpodState>()(
console.error("Error rehydrating iPod store:", error);
} else if (state && state.libraryState === "uninitialized") {
// Only auto-initialize if library state is uninitialized
Promise.resolve(state.initializeLibrary()).catch((err) =>
void runWithCloudSyncMutationSource(
"system-bootstrap",
() => state.initializeLibrary(),
"ipod-store:initializeLibrary"
).catch((err) =>
console.error("Initialization failed on rehydrate", err)
);
}
Expand Down
78 changes: 59 additions & 19 deletions src/utils/cloudSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,22 @@ import {
normalizeDeletionMarkerMap,
type DeletionMarkerMap,
} from "@/utils/cloudSyncDeletionMarkers";
import {
planIndividualBlobDomainApply,
resolveIndividualBlobApplyMode,
type IndividualBlobApplyMode,
type IndividualBlobDownloadApplyMode,
} from "@/utils/cloudSyncIndividualBlobApply";
import { runWithCloudSyncMutationSource } from "@/utils/cloudSyncMutationSource";
type AuthContext = {
username: string;
isAuthenticated: boolean;
};

export interface DownloadCloudSyncDomainOptions {
applyMode?: IndividualBlobDownloadApplyMode;
}

let _syncSessionId: string | null = null;

/** Stable per-tab identifier used to skip self-originated realtime events. */
Expand Down Expand Up @@ -997,30 +1008,38 @@ async function applyIndividualBlobDomain(
domain: IndividualBlobSyncDomain,
remoteKeys: string[],
changedItems: Record<string, StoreItemWithKey>,
deletedKeys: DeletionMarkerMap = {}
deletedKeys: DeletionMarkerMap = {},
mode: IndividualBlobApplyMode = "incremental"
): Promise<void> {
const storeName = getIndividualBlobStoreName(domain);
const db = await ensureIndexedDBInitialized();
const effectiveRemoteKeys = remoteKeys.filter((key) => !deletedKeys[key]);
let finalKeys: string[] = [];

try {
const existingItems = await readStoreItems(db, storeName);
const existingKeys = new Set(existingItems.map((item) => item.key));
const remoteKeySet = new Set(effectiveRemoteKeys);
const keysToDelete = Array.from(existingKeys).filter((key) => !remoteKeySet.has(key));
const plan = planIndividualBlobDomainApply({
mode,
existingKeys: existingItems.map((item) => item.key),
remoteKeys,
changedItemKeys: Object.keys(changedItems),
deletedKeys,
});

await deleteStoreItemsByKey(db, storeName, keysToDelete);
await deleteStoreItemsByKey(db, storeName, plan.keysToDelete);
await upsertStoreItems(
db,
storeName,
Object.values(changedItems).filter((item) => !deletedKeys[item.key])
plan.keysToUpsert
.map((key) => changedItems[key])
.filter((item): item is StoreItemWithKey => Boolean(item))
);
finalKeys = plan.finalKeys;
} finally {
db.close();
}

if (domain === "custom-wallpapers") {
await finalizeCustomWallpaperSync(effectiveRemoteKeys);
await finalizeCustomWallpaperSync(finalKeys);
}
}

Expand Down Expand Up @@ -1466,7 +1485,8 @@ async function downloadRedisStateDomain(

async function downloadBlobDomain(
domain: BlobSyncDomain,
_auth: AuthContext
_auth: AuthContext,
options: DownloadCloudSyncDomainOptions = {}
): Promise<CloudSyncDomainMetadata> {
const data = await fetchBlobDomainInfo(domain, _auth);
if (!data?.metadata) {
Expand All @@ -1482,6 +1502,14 @@ async function downloadBlobDomain(
remoteDeletedItems
);
const localRecords = await serializeIndividualBlobDomainRecords(domain);
const domainStatus = useCloudSyncStore.getState().domainStatus[domain];
const applyMode = resolveIndividualBlobApplyMode({
requestedMode: options.applyMode,
localItemCount: localRecords.length,
hasSyncHistory: Boolean(
domainStatus.lastAppliedRemoteAt || domainStatus.lastUploadedAt
),
});
const localRecordMap = new Map(
localRecords.map((record) => [record.item.key, record])
);
Expand All @@ -1499,7 +1527,11 @@ async function downloadBlobDomain(
}

const localRecord = localRecordMap.get(itemKey);
if (localRecord && localRecord.signature === itemMetadata.signature) {
if (
applyMode !== "replace" &&
localRecord &&
localRecord.signature === itemMetadata.signature
) {
continue;
}

Expand All @@ -1513,7 +1545,8 @@ async function downloadBlobDomain(
domain,
Object.keys(remoteItems),
changedItems,
effectiveDeletedItems
effectiveDeletedItems,
applyMode
);
return data.metadata;
}
Expand All @@ -1530,13 +1563,20 @@ async function downloadBlobDomain(

export async function downloadAndApplyCloudSyncDomain(
domain: CloudSyncDomain,
_auth: AuthContext
_auth: AuthContext,
options: DownloadCloudSyncDomainOptions = {}
): Promise<CloudSyncDomainMetadata> {
if (isRedisSyncDomain(domain)) {
return downloadRedisStateDomain(domain, _auth);
}
if (isBlobSyncDomain(domain)) {
return downloadBlobDomain(domain, _auth);
}
throw new Error(`Unknown sync domain: ${domain}`);
return runWithCloudSyncMutationSource(
"remote-sync",
async () => {
if (isRedisSyncDomain(domain)) {
return downloadRedisStateDomain(domain, _auth);
}
if (isBlobSyncDomain(domain)) {
return downloadBlobDomain(domain, _auth, options);
}
throw new Error(`Unknown sync domain: ${domain}`);
},
domain
);
}
87 changes: 87 additions & 0 deletions src/utils/cloudSyncIndividualBlobApply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { DeletionMarkerMap } from "@/utils/cloudSyncDeletionMarkers";

export type IndividualBlobApplyMode = "incremental" | "replace";
export type IndividualBlobDownloadApplyMode =
| "auto"
| IndividualBlobApplyMode;

interface ResolveIndividualBlobApplyModeParams {
requestedMode?: IndividualBlobDownloadApplyMode;
localItemCount: number;
hasSyncHistory: boolean;
}

interface PlanIndividualBlobDomainApplyParams {
mode: IndividualBlobApplyMode;
existingKeys: Iterable<string>;
remoteKeys: Iterable<string>;
changedItemKeys: Iterable<string>;
deletedKeys?: DeletionMarkerMap;
}

export interface IndividualBlobApplyPlan {
keysToDelete: string[];
keysToUpsert: string[];
finalKeys: string[];
}

export function resolveIndividualBlobApplyMode({
requestedMode = "auto",
localItemCount,
hasSyncHistory,
}: ResolveIndividualBlobApplyModeParams): IndividualBlobApplyMode {
if (requestedMode === "incremental" || requestedMode === "replace") {
return requestedMode;
}

if (!hasSyncHistory && localItemCount === 0) {
return "replace";
}

return "incremental";
}

export function planIndividualBlobDomainApply({
mode,
existingKeys,
remoteKeys,
changedItemKeys,
deletedKeys = {},
}: PlanIndividualBlobDomainApplyParams): IndividualBlobApplyPlan {
const existingKeySet = new Set(existingKeys);
const filteredRemoteKeySet = new Set(
Array.from(remoteKeys).filter((key) => !deletedKeys[key])
);
const changedKeySet = new Set(
Array.from(changedItemKeys).filter((key) => !deletedKeys[key])
);

if (mode === "replace") {
return {
keysToDelete: Array.from(existingKeySet).filter(
(key) => !filteredRemoteKeySet.has(key)
),
keysToUpsert: Array.from(changedKeySet),
finalKeys: Array.from(filteredRemoteKeySet),
};
}

const deletedKeySet = new Set(
Object.keys(deletedKeys).filter((key) => existingKeySet.has(key))
);
const finalKeySet = new Set(existingKeySet);

for (const key of deletedKeySet) {
finalKeySet.delete(key);
}

for (const key of changedKeySet) {
finalKeySet.add(key);
}

return {
keysToDelete: Array.from(deletedKeySet),
keysToUpsert: Array.from(changedKeySet),
finalKeys: Array.from(finalKeySet),
};
}
Loading
Loading