diff --git a/apps/desktop/layer/renderer/src/initialize/index.ts b/apps/desktop/layer/renderer/src/initialize/index.ts index ff9028aa7..c5333468d 100644 --- a/apps/desktop/layer/renderer/src/initialize/index.ts +++ b/apps/desktop/layer/renderer/src/initialize/index.ts @@ -2,6 +2,8 @@ import { initializeDayjs } from "@follow/components/dayjs" import { registerGlobalContext } from "@follow/shared/bridge" import { DEV, ELECTRON_BUILD, IN_ELECTRON } from "@follow/shared/constants" import { hydrateDatabaseToStore } from "@follow/store/hydrate" +import { whoami } from "@follow/store/user/getters" +import { userSyncService } from "@follow/store/user/store" import { tracker } from "@follow/tracker" import { repository } from "@pkg" import { enableMapSet } from "immer" @@ -82,14 +84,25 @@ export const initializeApp = async () => { apm("initializeSettings", initializeSettings) await apm("i18n", initI18n) - - apm("setting sync", () => { - settingSyncQueue.init() - settingSyncQueue.syncLocal() - }) - await apm("initAnalytics", initAnalytics) + void apm("setting sync", async () => { + await settingSyncQueue.init() + + await userSyncService.whoami().catch(() => null) + + if (!whoami()) { + return + } + await settingSyncQueue.syncLocal() + }).catch((error) => { + appLog("setting sync failed", error) + void tracker.manager.captureException(error, { + module: "setting_sync", + stage: "bootstrap", + }) + }) + const loadingTime = Date.now() - now appLog(`Initialize ${APP_NAME} done,`, `${loadingTime}ms`) diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts index 9dc838a66..414ca2816 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts @@ -1,8 +1,11 @@ import type { AISettings, GeneralSettings, UISettings } from "@follow/shared/settings/interface" +import { whoami } from "@follow/store/user/getters" +import { tracker } from "@follow/tracker" import { EventBus } from "@follow/utils/event-bus" import { getStorageNS } from "@follow/utils/ns" import { isEmptyObject, sleep } from "@follow/utils/utils" import type { SettingsTab } from "@follow-app/client-sdk" +import { FollowAPIError } from "@follow-app/client-sdk" import { omit } from "es-toolkit/compat" import type { PrimitiveAtom } from "jotai" @@ -56,14 +59,56 @@ const bizSettingKeyToTabMapping = { ai: "ai", } +const isUnauthorizedError = (error: unknown) => { + if (error instanceof FollowAPIError) { + return error.status === 401 + } + + if (error && typeof error === "object" && "status" in error) { + return Number((error as { status?: unknown }).status) === 401 + } + + return false +} + export type SettingSyncTab = keyof SettingMapping export interface SettingSyncQueueItem { tab: T payload: Partial date: number } + +interface PersistedSettingSyncQueue { + ownerUserId: string | null + queue: SettingSyncQueueItem[] +} + class SettingSyncQueue { queue: SettingSyncQueueItem[] = [] + private ownerUserId: string | null = null + + private getCurrentUserId() { + return whoami()?.id ?? null + } + + private bindQueueOwner(currentUserId: string) { + if (this.ownerUserId === null) { + this.ownerUserId = currentUserId + return + } + + if (this.ownerUserId !== currentUserId) { + this.ownerUserId = currentUserId + this.queue = [] + } + } + + private reportSyncError(stage: "flush" | "syncLocal", error: unknown) { + void tracker.manager.captureException(error, { + module: "setting_sync", + stage, + }) + } private disposers: (() => void)[] = [] async init() { @@ -72,6 +117,11 @@ class SettingSyncQueue { this.load() const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) return + + this.bindQueueOwner(currentUserId) + const tab = bizSettingKeyToTabMapping[data.key] if (!tab) return @@ -97,14 +147,21 @@ class SettingSyncQueue { disposer() } this.queue = [] + this.ownerUserId = null } private readonly storageKey = getStorageNS("setting_sync_queue") private persist() { if (this.queue.length === 0) { + localStorage.removeItem(this.storageKey) return } - localStorage.setItem(this.storageKey, JSON.stringify(this.queue)) + + const payload: PersistedSettingSyncQueue = { + ownerUserId: this.ownerUserId, + queue: this.queue, + } + localStorage.setItem(this.storageKey, JSON.stringify(payload)) } private load() { @@ -114,11 +171,37 @@ class SettingSyncQueue { return } + const currentUserId = this.getCurrentUserId() + try { - this.queue = JSON.parse(queue) + const parsed = JSON.parse(queue) as unknown + if (Array.isArray(parsed)) { + // Backward compatibility: legacy versions persisted the queue array directly. + this.queue = parsed + this.ownerUserId = currentUserId + } else if (!parsed || typeof parsed !== "object") { + this.queue = [] + this.ownerUserId = null + return + } else { + const payload = parsed as Partial + this.queue = Array.isArray(payload.queue) ? payload.queue : [] + if (typeof payload.ownerUserId === "string" || payload.ownerUserId === null) { + this.ownerUserId = payload.ownerUserId + } else { + // Backward compatibility for payloads without owner information. + this.ownerUserId = currentUserId + } + } } catch { /* empty */ } + + if (!currentUserId) { + return + } + + this.bindQueueOwner(currentUserId) } private chain = Promise.resolve() @@ -127,6 +210,13 @@ class SettingSyncQueue { private enqueueTime = Date.now() async enqueue(tab: T, payload: Partial) { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) { + return + } + + this.bindQueueOwner(currentUserId) + const now = Date.now() if (isEmptyObject(payload)) { return @@ -144,6 +234,13 @@ class SettingSyncQueue { } private async flush() { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) { + return + } + + this.bindQueueOwner(currentUserId) + if (navigator.onLine === false) { return } @@ -191,10 +288,27 @@ class SettingSyncQueue { promises.push(promise) } - await Promise.all(promises) + try { + await Promise.all(promises) + } catch (error) { + if (isUnauthorizedError(error)) { + this.queue = [] + this.ownerUserId = currentUserId + return + } + + this.reportSyncError("flush", error) + } } replaceRemote(tab?: SettingSyncTab) { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) { + return this.chain + } + + this.bindQueueOwner(currentUserId) + if (!tab) { const promises = [] as Promise[] for (const tab in localSettingGetterMap) { @@ -225,7 +339,24 @@ class SettingSyncQueue { } async syncLocal() { - const remoteSettings = await settings.get().prefetch() + const currentUserId = this.getCurrentUserId() + if (!currentUserId) return + + this.bindQueueOwner(currentUserId) + + const remoteSettings = await settings + .get() + .prefetch() + .catch((error) => { + if (isUnauthorizedError(error)) { + this.queue = [] + this.ownerUserId = currentUserId + return null + } + + this.reportSyncError("syncLocal", error) + return null + }) if (!remoteSettings) return diff --git a/apps/mobile/src/initialize/index.ts b/apps/mobile/src/initialize/index.ts index e9c957fa4..92e9b959b 100644 --- a/apps/mobile/src/initialize/index.ts +++ b/apps/mobile/src/initialize/index.ts @@ -1,5 +1,7 @@ import { initializeDB } from "@follow/database/db" import { hydrateDatabaseToStore } from "@follow/store/hydrate" +import { whoami } from "@follow/store/user/getters" +import { userSyncService } from "@follow/store/user/store" import { tracker } from "@follow/tracker" import { nativeApplicationVersion } from "expo-application" @@ -14,6 +16,20 @@ import { hydrateQueryClient, hydrateSettings } from "./hydrate" import { migrateDatabase } from "./migration" import { initializePlayer } from "./player" +type RequestIdleCallback = (callback: () => void, options?: { timeout?: number }) => number + +const runWhenIdle = (callback: () => void) => { + const requestIdle = (globalThis as { requestIdleCallback?: RequestIdleCallback }) + .requestIdleCallback + + if (requestIdle) { + requestIdle(callback, { timeout: 5000 }) + return + } + + setTimeout(callback, 0) +} + /* eslint-disable no-console */ export const initializeApp = async () => { console.log(`Initialize...`) @@ -38,19 +54,27 @@ export const initializeApp = async () => { dataHydratedTime = Date.now() - dataHydratedTime await apm("hydrateQueryClient", hydrateQueryClient) await apm("initializeAppCheck", initializeAppCheck) - requestIdleCallback( - () => { - apm("initializePlayer", initializePlayer) - }, - { timeout: 5000 }, // Max delay of 5 seconds - ) - - apm("setting sync", () => { - settingSyncQueue.init() - settingSyncQueue.syncLocal() + runWhenIdle(() => { + apm("initializePlayer", initializePlayer) }) - await initAnalytics() + + void apm("setting sync", async () => { + await settingSyncQueue.init() + + await userSyncService.whoami().catch(() => null) + + if (!whoami()) { + return + } + await settingSyncQueue.syncLocal() + }).catch((error) => { + console.error("setting sync failed", error) + void tracker.manager.captureException(error, { + module: "setting_sync", + stage: "bootstrap", + }) + }) const loadingTime = Date.now() - now tracker.appInit({ rn: true, diff --git a/apps/mobile/src/modules/settings/sync-queue.ts b/apps/mobile/src/modules/settings/sync-queue.ts index a980733d5..4911c5993 100644 --- a/apps/mobile/src/modules/settings/sync-queue.ts +++ b/apps/mobile/src/modules/settings/sync-queue.ts @@ -1,7 +1,10 @@ import type { GeneralSettings, UISettings } from "@follow/shared/settings/interface" +import { whoami } from "@follow/store/user/getters" +import { tracker } from "@follow/tracker" import { isEmptyObject, jotaiStore, sleep } from "@follow/utils" import { EventBus } from "@follow/utils/event-bus" import type { SettingsTab } from "@follow-app/client-sdk" +import { FollowAPIError } from "@follow-app/client-sdk" import { omit } from "es-toolkit/compat" import type { PrimitiveAtom } from "jotai" @@ -47,6 +50,18 @@ const bizSettingKeyToTabMapping = { general: "general", } +const isUnauthorizedError = (error: unknown) => { + if (error instanceof FollowAPIError) { + return error.status === 401 + } + + if (error && typeof error === "object" && "status" in error) { + return Number((error as { status?: unknown }).status) === 401 + } + + return false +} + export type SettingSyncTab = keyof SettingMapping export interface SettingSyncQueueItem { tab: T @@ -54,6 +69,11 @@ export interface SettingSyncQueueItem date: number } +interface PersistedSettingSyncQueue { + ownerUserId: string | null + queue: SettingSyncQueueItem[] +} + declare module "@follow/utils/event-bus" { interface CustomEvent { SETTING_CHANGE_EVENT: { @@ -65,14 +85,49 @@ declare module "@follow/utils/event-bus" { class SettingSyncQueue { queue: SettingSyncQueueItem[] = [] + private ownerUserId: string | null = null + + private getCurrentUserId() { + return whoami()?.id ?? null + } + + private bindQueueOwner(currentUserId: string) { + if (this.ownerUserId === null) { + this.ownerUserId = currentUserId + return + } + + if (this.ownerUserId !== currentUserId) { + this.ownerUserId = currentUserId + this.queue = [] + } + } + + private reportSyncError(stage: "flush" | "syncLocal", error: unknown) { + void tracker.manager.captureException(error, { + module: "setting_sync", + stage, + }) + } + + private async clearQueueAndPersist(ownerUserId: string | null) { + this.queue = [] + this.ownerUserId = ownerUserId + await kv.delete(this.storageKey) + } private disposers: (() => void)[] = [] async init() { this.teardown() - this.load() + const loadPromise = this.load() const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) return + + this.bindQueueOwner(currentUserId) + const tab = bizSettingKeyToTabMapping[data.key] as SettingSyncTab if (!tab) return @@ -80,10 +135,12 @@ class SettingSyncQueue { if (isEmptyObject(nextPayload)) return this.enqueue(tab, nextPayload) - this.persist() + void this.persist() }) this.disposers.push(d1) + + await loadPromise } teardown() { @@ -91,28 +148,69 @@ class SettingSyncQueue { disposer() } this.queue = [] + this.ownerUserId = null } private readonly storageKey = "setting_sync_queue" private async persist() { if (this.queue.length === 0) { + kv.delete(this.storageKey) return } - kv.set(this.storageKey, JSON.stringify(this.queue)) + + const payload: PersistedSettingSyncQueue = { + ownerUserId: this.ownerUserId, + queue: this.queue, + } + kv.set(this.storageKey, JSON.stringify(payload)) } private async load() { const queue = await kv.get(this.storageKey) - kv.delete(this.storageKey) + await kv.delete(this.storageKey) if (!queue) { return } + const currentUserId = this.getCurrentUserId() + let nextQueue: SettingSyncQueueItem[] = [] + let nextOwnerUserId: string | null = null + try { - this.queue = JSON.parse(queue) + const parsed = JSON.parse(queue) as unknown + if (Array.isArray(parsed)) { + // Backward compatibility: legacy versions persisted the queue array directly. + nextQueue = parsed + nextOwnerUserId = currentUserId + } else if (!parsed || typeof parsed !== "object") { + return + } else { + const payload = parsed as Partial + nextQueue = Array.isArray(payload.queue) ? payload.queue : [] + if (typeof payload.ownerUserId === "string" || payload.ownerUserId === null) { + nextOwnerUserId = payload.ownerUserId + } else { + // Backward compatibility for payloads without owner information. + nextOwnerUserId = currentUserId + } + } } catch { - /* empty */ + return } + + // If queue state has already changed after init starts, keep the newer in-memory state. + if (this.queue.length > 0 || this.ownerUserId !== null) { + return + } + + this.queue = nextQueue + this.ownerUserId = nextOwnerUserId + + if (!currentUserId) { + return + } + + this.bindQueueOwner(currentUserId) } private chain = Promise.resolve() @@ -121,6 +219,13 @@ class SettingSyncQueue { private enqueueTime = Date.now() async enqueue(tab: T, payload: Partial) { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) { + return + } + + this.bindQueueOwner(currentUserId) + const now = Date.now() if (isEmptyObject(payload)) { return @@ -138,6 +243,13 @@ class SettingSyncQueue { } private async flush() { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) { + return + } + + this.bindQueueOwner(currentUserId) + if (navigator.onLine === false) { return } @@ -189,10 +301,26 @@ class SettingSyncQueue { promises.push(promise) } - await Promise.all(promises) + try { + await Promise.all(promises) + } catch (error) { + if (isUnauthorizedError(error)) { + await this.clearQueueAndPersist(currentUserId) + return + } + + this.reportSyncError("flush", error) + } } replaceRemote(tab?: SettingSyncTab) { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) { + return this.chain + } + + this.bindQueueOwner(currentUserId) + if (!tab) { const promises = [] as Promise[] for (const tab in localSettingGetterMap) { @@ -238,7 +366,23 @@ class SettingSyncQueue { return promise } async syncLocal() { - const remoteSettings = await this.fetchSettingRemote() + const currentUserId = this.getCurrentUserId() + if (!currentUserId) return + + this.bindQueueOwner(currentUserId) + + let remoteSettings: Awaited> | null = null + try { + remoteSettings = await this.fetchSettingRemote() + } catch (error) { + if (isUnauthorizedError(error)) { + await this.clearQueueAndPersist(currentUserId) + return + } + + this.reportSyncError("syncLocal", error) + return + } if (!remoteSettings) return if (__DEV__) {