390 lines · 14.2 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import type { SidebarProjectGroupingMode } from "@t3tools/contracts";import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database";import * as MobileSecureStorage from "./mobile-secure-storage";import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; const PREFERENCES_KEY = "t3code.preferences";const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; export interface Preferences {  readonly liveActivitiesEnabled?: boolean;  readonly themeId?: MobileThemeId;  readonly lightThemeId?: MobileThemeId;  readonly darkThemeId?: MobileThemeId;  readonly themeMode?: MobileThemeMode;  readonly baseFontSize?: number;  readonly terminalFontSize?: number | null;  readonly markdownFontSize?: number;  readonly codeFontSize?: number | null;  readonly codeWordBreak?: boolean;  readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;  readonly collapsedProjectGroups?: readonly string[];  /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */  readonly projectGroupingEnabled?: boolean;  readonly projectGroupingMode?: SidebarProjectGroupingMode;  readonly autoSettleOnMerge?: boolean;  /**   * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has   * no client-settings sync, so the legacy grouped thread list is opted into   * per device. Deliberately a fresh key (was `threadListV2Enabled`, an   * opt-out): sanitizing drops the old key, so every device resets to the   * default flat list — see `resolveThreadListV2Enabled`.   */  readonly legacyThreadListEnabled?: boolean;  /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */  readonly planModeEnabled?: boolean;  /** Undefined preserves the default expanded Settled shelf. */  readonly threadListV2SettledShelfExpanded?: boolean;  /** Undefined preserves the default collapsed Snoozed shelf. */  readonly threadListV2SnoozedShelfExpanded?: boolean;} export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(  "MobilePreferencesLoadError",  { cause: Schema.Defect() },) {  override get message(): string {    return "Failed to load mobile preferences.";  }} export class MobilePreferencesSaveError extends Schema.TaggedErrorClass<MobilePreferencesSaveError>()(  "MobilePreferencesSaveError",  { cause: Schema.Defect() },) {  override get message(): string {    return "Failed to save mobile preferences.";  }} interface PreferencesFallback {  readonly payload: string;  readonly updatedAt: number;  readonly preferences: Preferences;} export class MobilePreferencesStore extends Context.Service<  MobilePreferencesStore,  {    readonly load: Effect.Effect<Preferences, MobilePreferencesLoadError>;    readonly savePatch: (      patch: Partial<Preferences>,    ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;    readonly update: (      transform: (current: Preferences) => Partial<Preferences>,    ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;  }>()("@t3tools/mobile/persistence/MobilePreferencesStore") {} function sanitizePreferences(parsed: Preferences): Preferences {  const preferences: {    liveActivitiesEnabled?: boolean;    themeId?: MobileThemeId;    lightThemeId?: MobileThemeId;    darkThemeId?: MobileThemeId;    themeMode?: MobileThemeMode;    baseFontSize?: number;    terminalFontSize?: number | null;    markdownFontSize?: number;    codeFontSize?: number | null;    codeWordBreak?: boolean;    connectOnboardingOptOutAccounts?: ReadonlyArray<string>;    collapsedProjectGroups?: readonly string[];    projectGroupingEnabled?: boolean;    projectGroupingMode?: SidebarProjectGroupingMode;    autoSettleOnMerge?: boolean;    legacyThreadListEnabled?: boolean;    planModeEnabled?: boolean;    threadListV2SettledShelfExpanded?: boolean;    threadListV2SnoozedShelfExpanded?: boolean;  } = {};   if (typeof parsed.liveActivitiesEnabled === "boolean") {    preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;  }  if (    typeof parsed.themeId === "string" &&    (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId)  ) {    preferences.themeId = parsed.themeId as MobileThemeId;  }  if (    typeof parsed.lightThemeId === "string" &&    (MOBILE_THEME_IDS as readonly string[]).includes(parsed.lightThemeId)  ) {    preferences.lightThemeId = parsed.lightThemeId as MobileThemeId;  }  if (    typeof parsed.darkThemeId === "string" &&    (MOBILE_THEME_IDS as readonly string[]).includes(parsed.darkThemeId)  ) {    preferences.darkThemeId = parsed.darkThemeId as MobileThemeId;  }  if (    parsed.themeMode === "system" ||    parsed.themeMode === "light" ||    parsed.themeMode === "dark"  ) {    preferences.themeMode = parsed.themeMode;  }  if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;  if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {    preferences.terminalFontSize = parsed.terminalFontSize;  }  if (typeof parsed.markdownFontSize === "number") {    preferences.markdownFontSize = parsed.markdownFontSize;  }  if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) {    preferences.codeFontSize = parsed.codeFontSize;  }  if (typeof parsed.codeWordBreak === "boolean") preferences.codeWordBreak = parsed.codeWordBreak;  if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {    preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(      (account): account is string => typeof account === "string",    );  }  if (Array.isArray(parsed.collapsedProjectGroups)) {    preferences.collapsedProjectGroups = parsed.collapsedProjectGroups.filter(      (key): key is string => typeof key === "string",    );  }  if (typeof parsed.projectGroupingEnabled === "boolean") {    preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;  }  if (    parsed.projectGroupingMode === "repository" ||    parsed.projectGroupingMode === "repository_path" ||    parsed.projectGroupingMode === "separate"  ) {    preferences.projectGroupingMode = parsed.projectGroupingMode;  }  if (typeof parsed.autoSettleOnMerge === "boolean") {    preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;  }  if (typeof parsed.legacyThreadListEnabled === "boolean") {    preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;  }  if (typeof parsed.planModeEnabled === "boolean") {    preferences.planModeEnabled = parsed.planModeEnabled;  }  if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") {    preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded;  }  if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") {    preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded;  }  return preferences;} export const make = Effect.fn("MobilePreferencesStore.make")(function* () {  const database = yield* MobileDatabase.MobileDatabase;  const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;  const lock = yield* Semaphore.make(1);  const lastUpdatedAt = yield* Ref.make(0);   const parsePayload = (raw: string | null): Preferences | null => {    if (raw === null || !raw.trim()) return null;    let parsed: unknown;    try {      parsed = JSON.parse(raw);    } catch (cause) {      console.warn(        "[mobile-storage] ignored invalid JSON",        new MobileStorageDecodeError({ key: PREFERENCES_KEY, cause }),      );      return null;    }    return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)      ? (parsed as Preferences)      : null;  };   const parseFallback = (raw: string | null): PreferencesFallback | null => {    if (raw === null || !raw.trim()) return null;    let parsed: unknown;    try {      parsed = JSON.parse(raw);    } catch (cause) {      console.warn(        "[mobile-storage] ignored invalid JSON",        new MobileStorageDecodeError({ key: PREFERENCES_FALLBACK_KEY, cause }),      );      return null;    }    if (      typeof parsed !== "object" ||      parsed === null ||      !("payload" in parsed) ||      typeof parsed.payload !== "string" ||      !("updatedAt" in parsed) ||      typeof parsed.updatedAt !== "number"    ) {      return null;    }    const preferences = parsePayload(parsed.payload);    return preferences === null      ? null      : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences };  };   const encode = Effect.fn("MobilePreferencesStore.encode")(function* (    key: string,    value: unknown,  ) {    return yield* Effect.try({      try: () => JSON.stringify(value),      catch: (cause) => new MobileStorageEncodeError({ key, cause }),    });  });   const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => {    const next = Math.max(Date.now(), last + 1);    return [next, next] as const;  });   const saveJson = Effect.fn("MobilePreferencesStore.saveJson")(function* (    payload: string,    updatedAt?: number,  ) {    const timestamp = updatedAt ?? (yield* nextUpdatedAt);    yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, timestamp));    const databaseResult = yield* Effect.result(database.savePreferencesJson(payload, timestamp));    if (databaseResult._tag === "Failure") {      yield* Effect.logWarning("Database unavailable; saving preferences to secure storage.").pipe(        Effect.annotateLogs({ cause: databaseResult.failure }),      );      const fallback = yield* encode(PREFERENCES_FALLBACK_KEY, { payload, updatedAt: timestamp });      yield* secureStorage.setItem(PREFERENCES_FALLBACK_KEY, fallback);      return;    }    yield* secureStorage      .removeItem(PREFERENCES_FALLBACK_KEY)      .pipe(        Effect.catch((error) =>          Effect.logWarning("Could not remove the mobile preferences fallback.").pipe(            Effect.annotateLogs({ error }),          ),        ),      );  });   const loadUnlocked = Effect.gen(function* () {    const databaseResult = yield* Effect.result(database.loadPreferencesJson);    const databaseAvailable = databaseResult._tag === "Success";    const storedJson = databaseAvailable      ? databaseResult.success      : Option.none<MobileDatabase.StoredPreferencesJson>();    if (databaseResult._tag === "Failure") {      yield* Effect.logWarning("Database unavailable; loading fallback preferences.").pipe(        Effect.annotateLogs({ cause: databaseResult.failure }),      );    }     const fallbackResult = yield* Effect.result(secureStorage.getItem(PREFERENCES_FALLBACK_KEY));    let fallbackJson: string | null = null;    if (fallbackResult._tag === "Success") {      fallbackJson = fallbackResult.success;    } else if (Option.isNone(storedJson)) {      return yield* fallbackResult.failure;    } else {      yield* Effect.logWarning("Could not inspect the mobile preferences fallback.").pipe(        Effect.annotateLogs({ error: fallbackResult.failure }),      );    }     const fallback = parseFallback(fallbackJson);    const storedPreferences = Option.isSome(storedJson)      ? parsePayload(storedJson.value.payload)      : null;    const fallbackIsNewer =      fallback !== null &&      (storedPreferences === null ||        (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt));     let parsed: Preferences | null = null;    if (fallbackIsNewer) {      parsed = fallback.preferences;      yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, fallback.updatedAt));      if (databaseAvailable) yield* saveJson(fallback.payload, fallback.updatedAt);    } else if (storedPreferences !== null && Option.isSome(storedJson)) {      parsed = storedPreferences;      yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, storedJson.value.updatedAt));      if (fallbackJson !== null) {        yield* secureStorage          .removeItem(PREFERENCES_FALLBACK_KEY)          .pipe(            Effect.catch((error) =>              Effect.logWarning("Could not remove a stale mobile preferences fallback.").pipe(                Effect.annotateLogs({ error }),              ),            ),          );      }    }     if (parsed === null) {      const legacyJson = yield* secureStorage.getItem(PREFERENCES_KEY);      const legacyPreferences = parsePayload(legacyJson);      parsed = legacyPreferences;      if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) {        yield* saveJson(legacyJson);        yield* secureStorage          .removeItem(PREFERENCES_KEY)          .pipe(            Effect.catch((error) =>              Effect.logWarning("Could not remove migrated mobile preferences.").pipe(                Effect.annotateLogs({ error }),              ),            ),          );      }    }     return parsed === null ? {} : sanitizePreferences(parsed);  });   const load = lock    .withPermits(1)(loadUnlocked)    .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause })));   const update = Effect.fn("MobilePreferencesStore.update")((transform) =>    lock      .withPermits(1)(        Effect.gen(function* () {          const current = yield* loadUnlocked;          const patch = yield* Effect.try({            try: () => transform(current),            catch: (cause) => new MobilePreferencesSaveError({ cause }),          });          const next: Preferences = { ...current, ...patch };          const payload = yield* encode(PREFERENCES_KEY, next);          yield* saveJson(payload);          return next;        }),      )      .pipe(        Effect.mapError((cause) =>          cause instanceof MobilePreferencesSaveError            ? cause            : new MobilePreferencesSaveError({ cause }),        ),      ),  );   return MobilePreferencesStore.of({    load,    update,    savePatch: (patch) => update(() => patch),  });}); export const layer = Layer.effect(MobilePreferencesStore, make());