Skip to content
Source
Sign inSign up
Open History to see the last commit

mobile-preferences.ts

586 lines22.5 KB

1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8import {
9 EnvironmentId,
10 PullRequestInvolvement,
11 PullRequestListFilters,
12 PullRequestListState,
13 type ProviderInstanceId,
14 PullRequestMergeMethod,
15 type SidebarProjectGroupingMode,
16} from "@t3tools/contracts";
17import { ToolCallVisibility } from "@t3tools/contracts/settings";
18import type { AndroidNavigationStyle } from "../lib/androidNavigationStyle";
19import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
20import type { FollowUpBehavior } from "../lib/followUpBehavior";
21import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority";
22import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
23import * as MobileDatabase from "./mobile-database";
24import * as MobileSecureStorage from "./mobile-secure-storage";
25import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";
26​
27const PREFERENCES_KEY = "t3code.preferences";
28const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
29const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
30​
31export const PullRequestListControls = Schema.Struct({
32 state: PullRequestListState,
33 involvement: PullRequestInvolvement,
34 filters: PullRequestListFilters,
35 projectId: Schema.optional(Schema.String),
36 projectEnvironmentId: Schema.optional(Schema.String),
37 sort: Schema.Literals(["updated", "created", "oldest"]),
38});
39export type PullRequestListControls = typeof PullRequestListControls.Type;
40​
41const ThreadListFilters = Schema.Struct({
42 environmentId: Schema.NullOr(EnvironmentId),
43 projectKey: Schema.NullOr(Schema.String),
44});
45export type ThreadListFilters = typeof ThreadListFilters.Type;
46​
47const PullRequestListPreferences = Schema.Struct({
48 environmentId: Schema.NullOr(Schema.String),
49 controls: Schema.Record(Schema.String, PullRequestListControls),
50});
51export type PullRequestListPreferences = typeof PullRequestListPreferences.Type;
52​
53/**
54 * The page each workspace tab last showed, reopened when the tab returns with columns. `key` is
55 * that page's route key: reopening under the same key reuses the screen a tab kept mounted.
56 */
57const WorkspaceSelections = Schema.Struct({
58 threads: Schema.optional(
59 Schema.Struct({
60 environmentId: Schema.String,
61 threadId: Schema.String,
62 key: Schema.optional(Schema.String),
63 }),
64 ),
65 pullRequests: Schema.optional(
66 Schema.Struct({
67 environmentId: Schema.String,
68 projectId: Schema.String,
69 repository: Schema.String,
70 number: Schema.Number,
71 host: Schema.optional(Schema.String),
72 key: Schema.optional(Schema.String),
73 }),
74 ),
75 settings: Schema.optional(Schema.String),
76 settingsKey: Schema.optional(Schema.String),
77 /** A draft the threads tab showed after its last chat; only meaningful in its session. */
78 threadsDraft: Schema.optional(Schema.Struct({ key: Schema.String, params: Schema.Unknown })),
79});
80export type WorkspaceSelections = typeof WorkspaceSelections.Type;
81​
82const PullRequestActivityVisibility = Schema.Struct({
83 bots: Schema.Boolean,
84 resolved: Schema.Boolean,
85});
86export type PullRequestActivityVisibility = typeof PullRequestActivityVisibility.Type;
87​
88const isThreadListFilters = Schema.is(ThreadListFilters);
89const isPullRequestActivityVisibility = Schema.is(PullRequestActivityVisibility);
90const isWorkspaceSelections = Schema.is(WorkspaceSelections);
91const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);
92const ThreadProject = Schema.Struct({
93 environmentId: Schema.NonEmptyString,
94 projectId: Schema.NonEmptyString,
95});
96const isThreadProject = Schema.is(ThreadProject);
97​
98export interface Preferences {
99 readonly navigationTabOrder?: readonly string[];
100 readonly hiddenNavigationTabs?: readonly string[];
101 readonly pullRequestHideTestFiles?: boolean;
102 /** Which hidden-by-default PR activity is shown: bot comments and resolved or dismissed ones. */
103 readonly pullRequestActivityVisibility?: PullRequestActivityVisibility;
104 readonly pullRequestMergeMethod?: PullRequestMergeMethod;
105 readonly liveActivitiesEnabled?: boolean;
106 readonly themeId?: MobileThemeId;
107 readonly lightThemeId?: MobileThemeId;
108 readonly darkThemeId?: MobileThemeId;
109 readonly themeMode?: MobileThemeMode;
110 readonly androidNavigationStyle?: AndroidNavigationStyle;
111 readonly baseFontSize?: number;
112 readonly terminalFontSize?: number | null;
113 readonly markdownFontSize?: number;
114 readonly codeFontSize?: number | null;
115 readonly codeWordBreak?: boolean;
116 readonly mathRenderingEnabled?: boolean;
117 /** Unset means on: crash reports are sent unless the user turns them off. */
118 readonly crashReportsEnabled?: boolean;
119 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
120 readonly collapsedProjectGroups?: readonly string[];
121 readonly dismissedDatabaseStorageNotices?: readonly string[];
122 /** Hardware-keyboard Return behavior in the composer. */
123 readonly composerEnterBehavior?: ComposerEnterBehavior;
124 readonly followUpBehavior?: FollowUpBehavior;
125 /** The client `language` setting: a locale code or "system". Unset means English. */
126 readonly language?: string;
127 readonly microphones?: ReadonlyArray<MicrophoneEntry>;
128 readonly projectGroupingEnabled?: boolean;
129 readonly projectGroupingMode?: SidebarProjectGroupingMode;
130 readonly toolCallVisibility?: ToolCallVisibility;
131 readonly workingShelfEnabled?: boolean;
132 readonly useLastProjectForNewThreads?: boolean;
133 readonly lastThreadProject?: typeof ThreadProject.Type;
134 readonly modelFavorites?: ReadonlyArray<{
135 readonly provider: ProviderInstanceId;
136 readonly model: string;
137 }>;
138 readonly threadListSettledShelfExpanded?: boolean;
139 readonly threadListSnoozedShelfExpanded?: boolean;
140 readonly threadListWorkingShelfExpanded?: boolean;
141 readonly threadListFilters?: ThreadListFilters;
142 readonly pullRequestListFilters?: PullRequestListPreferences;
143 readonly workspaceSelections?: WorkspaceSelections;
145​
146export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(
147 "MobilePreferencesLoadError",
148 { cause: Schema.Defect() },
149) {
150 override get message(): string {
151 return "Failed to load mobile preferences.";
152 }
154​
155export class MobilePreferencesSaveError extends Schema.TaggedError<MobilePreferencesSaveError>()(
156 "MobilePreferencesSaveError",
157 { cause: Schema.Defect() },
158) {
159 override get message(): string {
160 return "Failed to save mobile preferences.";
161 }
163​
164const isMobilePreferencesSaveError = Schema.is(MobilePreferencesSaveError);
165​
166interface PreferencesFallback {
167 readonly payload: string;
168 readonly updatedAt: number;
169 readonly preferences: Preferences;
171​
172export class MobilePreferencesStore extends Context.Service<
173 MobilePreferencesStore,
174 {
175 readonly load: Effect.Effect<Preferences, MobilePreferencesLoadError>;
176 readonly savePatch: (
177 patch: Partial<Preferences>,
178 ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;
179 readonly update: (
180 transform: (current: Preferences) => Partial<Preferences>,
181 ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;
182 }
183>()("@t3tools/mobile/persistence/mobile-preferences/MobilePreferencesStore") {}
184​
185const isToolCallVisibility = Schema.is(ToolCallVisibility);
186const isPullRequestMergeMethod = Schema.is(PullRequestMergeMethod);
187​
188function sanitizePreferences(parsed: Preferences): Preferences {
189 const preferences: {
190 navigationTabOrder?: readonly string[];
191 hiddenNavigationTabs?: readonly string[];
192 pullRequestHideTestFiles?: boolean;
193 pullRequestActivityVisibility?: PullRequestActivityVisibility;
194 pullRequestMergeMethod?: PullRequestMergeMethod;
195 liveActivitiesEnabled?: boolean;
196 themeId?: MobileThemeId;
197 lightThemeId?: MobileThemeId;
198 darkThemeId?: MobileThemeId;
199 themeMode?: MobileThemeMode;
200 androidNavigationStyle?: AndroidNavigationStyle;
201 baseFontSize?: number;
202 terminalFontSize?: number | null;
203 markdownFontSize?: number;
204 codeFontSize?: number | null;
205 codeWordBreak?: boolean;
206 mathRenderingEnabled?: boolean;
207 crashReportsEnabled?: boolean;
208 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
209 collapsedProjectGroups?: readonly string[];
210 dismissedDatabaseStorageNotices?: readonly string[];
211 composerEnterBehavior?: ComposerEnterBehavior;
212 followUpBehavior?: FollowUpBehavior;
213 language?: string;
214 microphones?: ReadonlyArray<MicrophoneEntry>;
215 projectGroupingEnabled?: boolean;
216 projectGroupingMode?: SidebarProjectGroupingMode;
217 toolCallVisibility?: ToolCallVisibility;
218 workingShelfEnabled?: boolean;
219 useLastProjectForNewThreads?: boolean;
220 lastThreadProject?: typeof ThreadProject.Type;
221 modelFavorites?: Preferences["modelFavorites"];
222 threadListSettledShelfExpanded?: boolean;
223 threadListSnoozedShelfExpanded?: boolean;
224 threadListWorkingShelfExpanded?: boolean;
225 threadListFilters?: ThreadListFilters;
226 pullRequestListFilters?: PullRequestListPreferences;
227 workspaceSelections?: WorkspaceSelections;
228 } = {};
229​
230 if (Array.isArray(parsed.hiddenNavigationTabs)) {
231 preferences.hiddenNavigationTabs = parsed.hiddenNavigationTabs.filter(
232 (tab): tab is string => typeof tab === "string",
233 );
234 }
235​
236 if (Array.isArray(parsed.navigationTabOrder)) {
237 preferences.navigationTabOrder = parsed.navigationTabOrder.filter(
238 (tab): tab is string => typeof tab === "string",
239 );
240 }
241​
242 if (typeof parsed.pullRequestHideTestFiles === "boolean") {
243 preferences.pullRequestHideTestFiles = parsed.pullRequestHideTestFiles;
244 }
245​
246 if (isPullRequestActivityVisibility(parsed.pullRequestActivityVisibility)) {
247 preferences.pullRequestActivityVisibility = parsed.pullRequestActivityVisibility;
248 }
249​
250 if (isPullRequestMergeMethod(parsed.pullRequestMergeMethod)) {
251 preferences.pullRequestMergeMethod = parsed.pullRequestMergeMethod;
252 }
253​
254 if (typeof parsed.liveActivitiesEnabled === "boolean") {
255 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
256 }
257 if (
258 typeof parsed.themeId === "string" &&
259 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId)
260 ) {
261 preferences.themeId = parsed.themeId as MobileThemeId;
262 }
263 if (
264 typeof parsed.lightThemeId === "string" &&
265 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.lightThemeId)
266 ) {
267 preferences.lightThemeId = parsed.lightThemeId as MobileThemeId;
268 }
269 if (
270 typeof parsed.darkThemeId === "string" &&
271 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.darkThemeId)
272 ) {
273 preferences.darkThemeId = parsed.darkThemeId as MobileThemeId;
274 }
275 if (
276 parsed.themeMode === "system" ||
277 parsed.themeMode === "light" ||
278 parsed.themeMode === "dark"
279 ) {
280 preferences.themeMode = parsed.themeMode;
281 }
282 if (parsed.androidNavigationStyle === "one-ui" || parsed.androidNavigationStyle === "material") {
283 preferences.androidNavigationStyle = parsed.androidNavigationStyle;
284 }
285 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
286 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
287 preferences.terminalFontSize = parsed.terminalFontSize;
288 }
289 if (typeof parsed.markdownFontSize === "number") {
290 preferences.markdownFontSize = parsed.markdownFontSize;
291 }
292 if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) {
293 preferences.codeFontSize = parsed.codeFontSize;
294 }
295 if (typeof parsed.codeWordBreak === "boolean") preferences.codeWordBreak = parsed.codeWordBreak;
296 if (typeof parsed.mathRenderingEnabled === "boolean")
297 preferences.mathRenderingEnabled = parsed.mathRenderingEnabled;
298 if (typeof parsed.crashReportsEnabled === "boolean") {
299 preferences.crashReportsEnabled = parsed.crashReportsEnabled;
300 }
301 if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
302 preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
303 (account): account is string => typeof account === "string",
304 );
305 }
306 if (Array.isArray(parsed.collapsedProjectGroups)) {
307 preferences.collapsedProjectGroups = parsed.collapsedProjectGroups.filter(
308 (key): key is string => typeof key === "string",
309 );
310 }
311 if (Array.isArray(parsed.dismissedDatabaseStorageNotices)) {
312 preferences.dismissedDatabaseStorageNotices = parsed.dismissedDatabaseStorageNotices.filter(
313 (value): value is string => typeof value === "string",
314 );
315 }
316 if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") {
317 preferences.composerEnterBehavior = parsed.composerEnterBehavior;
318 }
319 if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") {
320 preferences.followUpBehavior = parsed.followUpBehavior;
321 }
322 if (typeof parsed.language === "string") preferences.language = parsed.language;
323 if (Array.isArray(parsed.microphones)) {
324 preferences.microphones = parsed.microphones.flatMap((entry): MicrophoneEntry[] => {
325 if (typeof entry !== "object" || entry === null || !isMicrophoneKind(entry.kind)) return [];
326 if (!("uid" in entry)) return [{ kind: entry.kind }];
327 return typeof entry.uid === "string" && typeof entry.name === "string"
328 ? [{ kind: entry.kind, uid: entry.uid, name: entry.name }]
329 : [];
330 });
331 }
332 if (typeof parsed.projectGroupingEnabled === "boolean") {
333 preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
334 }
335 if (
336 parsed.projectGroupingMode === "repository" ||
337 parsed.projectGroupingMode === "repository_path" ||
338 parsed.projectGroupingMode === "separate"
339 ) {
340 preferences.projectGroupingMode = parsed.projectGroupingMode;
341 }
342 if (isToolCallVisibility(parsed.toolCallVisibility)) {
343 preferences.toolCallVisibility = parsed.toolCallVisibility;
344 }
345 if (typeof parsed.workingShelfEnabled === "boolean") {
346 preferences.workingShelfEnabled = parsed.workingShelfEnabled;
347 }
348 if (typeof parsed.useLastProjectForNewThreads === "boolean") {
349 preferences.useLastProjectForNewThreads = parsed.useLastProjectForNewThreads;
350 }
351 if (isThreadProject(parsed.lastThreadProject)) {
352 preferences.lastThreadProject = parsed.lastThreadProject;
353 }
354 if (Array.isArray(parsed.modelFavorites)) {
355 preferences.modelFavorites = parsed.modelFavorites.filter(
356 (favorite) =>
357 typeof favorite === "object" &&
358 favorite !== null &&
359 typeof favorite.provider === "string" &&
360 favorite.provider.length > 0 &&
361 typeof favorite.model === "string" &&
362 favorite.model.trim().length > 0,
363 );
364 }
365 if (typeof parsed.threadListSettledShelfExpanded === "boolean") {
366 preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded;
367 }
368 if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") {
369 preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded;
370 }
371 if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {
372 preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;
373 }
374 if (isThreadListFilters(parsed.threadListFilters)) {
375 preferences.threadListFilters = parsed.threadListFilters;
376 }
377 if (isPullRequestListPreferences(parsed.pullRequestListFilters)) {
378 preferences.pullRequestListFilters = parsed.pullRequestListFilters;
379 }
380 if (isWorkspaceSelections(parsed.workspaceSelections)) {
381 preferences.workspaceSelections = parsed.workspaceSelections;
382 }
383 return preferences;
385​
386const make = Effect.fn("MobilePreferencesStore.make")(function* () {
387 const database = yield* MobileDatabase.MobileDatabase;
388 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
389 const lock = yield* Semaphore.make(1);
390 const lastUpdatedAt = yield* Ref.make(0);
391​
392 const parsePayload = (raw: string | null): Preferences | null => {
393 if (raw === null || !raw.trim()) return null;
394 let parsed: unknown;
395 try {
396 parsed = JSON.parse(raw);
397 } catch (cause) {
398 console.warn(
399 "[mobile-storage] ignored invalid JSON",
400 new MobileStorageDecodeError({ key: PREFERENCES_KEY, cause }),
401 );
402 return null;
403 }
404 return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
405 ? (parsed as Preferences)
406 : null;
407 };
408​
409 const parseFallback = (raw: string | null): PreferencesFallback | null => {
410 if (raw === null || !raw.trim()) return null;
411 let parsed: unknown;
412 try {
413 parsed = JSON.parse(raw);
414 } catch (cause) {
415 console.warn(
416 "[mobile-storage] ignored invalid JSON",
417 new MobileStorageDecodeError({ key: PREFERENCES_FALLBACK_KEY, cause }),
418 );
419 return null;
420 }
421 if (
422 typeof parsed !== "object" ||
423 parsed === null ||
424 !("payload" in parsed) ||
425 typeof parsed.payload !== "string" ||
426 !("updatedAt" in parsed) ||
427 typeof parsed.updatedAt !== "number"
428 ) {
429 return null;
430 }
431 const preferences = parsePayload(parsed.payload);
432 return preferences === null
433 ? null
434 : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences };
435 };
436​
437 const encode = Effect.fn("MobilePreferencesStore.encode")(function* (
438 key: string,
439 value: unknown,
440 ) {
441 return yield* encodeJson(value).pipe(
442 Effect.mapError((cause) => new MobileStorageEncodeError({ key, cause })),
443 );
444 });
445​
446 const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => {
447 const next = Math.max(Date.now(), last + 1);
448 return [next, next] as const;
449 });
450​
451 const saveJson = Effect.fn("MobilePreferencesStore.saveJson")(function* (
452 payload: string,
453 updatedAt?: number,
454 ) {
455 const timestamp = updatedAt ?? (yield* nextUpdatedAt);
456 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, timestamp));
457 const databaseResult = yield* Effect.result(database.savePreferencesJson(payload, timestamp));
458 if (databaseResult._tag === "Failure") {
459 yield* Effect.logWarning("Database unavailable; saving preferences to secure storage.").pipe(
460 Effect.annotateLogs({ cause: databaseResult.failure }),
461 );
462 const fallback = yield* encode(PREFERENCES_FALLBACK_KEY, { payload, updatedAt: timestamp });
463 yield* secureStorage.setItem(PREFERENCES_FALLBACK_KEY, fallback);
464 return;
465 }
466 yield* secureStorage
467 .removeItem(PREFERENCES_FALLBACK_KEY)
468 .pipe(
469 Effect.catch((error) =>
470 Effect.logWarning("Could not remove the mobile preferences fallback.").pipe(
471 Effect.annotateLogs({ error }),
472 ),
473 ),
474 );
475 });
476​
477 const loadUnlocked = Effect.gen(function* () {
478 const databaseResult = yield* Effect.result(database.loadPreferencesJson);
479 const databaseAvailable = databaseResult._tag === "Success";
480 const storedJson = databaseAvailable
481 ? databaseResult.success
482 : Option.none<MobileDatabase.StoredPreferencesJson>();
483 if (databaseResult._tag === "Failure") {
484 yield* Effect.logWarning("Database unavailable; loading fallback preferences.").pipe(
485 Effect.annotateLogs({ cause: databaseResult.failure }),
486 );
487 }
488​
489 const fallbackResult = yield* Effect.result(secureStorage.getItem(PREFERENCES_FALLBACK_KEY));
490 let fallbackJson: string | null = null;
491 if (fallbackResult._tag === "Success") {
492 fallbackJson = fallbackResult.success;
493 } else if (Option.isNone(storedJson)) {
494 return yield* fallbackResult.failure;
495 } else {
496 yield* Effect.logWarning("Could not inspect the mobile preferences fallback.").pipe(
497 Effect.annotateLogs({ error: fallbackResult.failure }),
498 );
499 }
500​
501 const fallback = parseFallback(fallbackJson);
502 const storedPreferences = Option.isSome(storedJson)
503 ? parsePayload(storedJson.value.payload)
504 : null;
505 const fallbackIsNewer =
506 fallback !== null &&
507 (storedPreferences === null ||
508 (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt));
509​
510 let parsed: Preferences | null = null;
511 if (fallbackIsNewer) {
512 parsed = fallback.preferences;
513 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, fallback.updatedAt));
514 if (databaseAvailable) yield* saveJson(fallback.payload, fallback.updatedAt);
515 } else if (storedPreferences !== null && Option.isSome(storedJson)) {
516 parsed = storedPreferences;
517 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, storedJson.value.updatedAt));
518 if (fallbackJson !== null) {
519 yield* secureStorage
520 .removeItem(PREFERENCES_FALLBACK_KEY)
521 .pipe(
522 Effect.catch((error) =>
523 Effect.logWarning("Could not remove a stale mobile preferences fallback.").pipe(
524 Effect.annotateLogs({ error }),
525 ),
526 ),
527 );
528 }
529 }
530​
531 if (parsed === null) {
532 const legacyJson = yield* secureStorage.getItem(PREFERENCES_KEY);
533 const legacyPreferences = parsePayload(legacyJson);
534 parsed = legacyPreferences;
535 if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) {
536 yield* saveJson(legacyJson);
537 yield* secureStorage
538 .removeItem(PREFERENCES_KEY)
539 .pipe(
540 Effect.catch((error) =>
541 Effect.logWarning("Could not remove migrated mobile preferences.").pipe(
542 Effect.annotateLogs({ error }),
543 ),
544 ),
545 );
546 }
547 }
548​
549 return parsed === null ? {} : sanitizePreferences(parsed);
550 });
551​
552 const load = lock
553 .withPermits(1)(loadUnlocked)
554 .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause })));
555​
556 const update = Effect.fn("MobilePreferencesStore.update")(
557 (transform: (current: Preferences) => Partial<Preferences>) =>
558 lock
559 .withPermits(1)(
560 Effect.gen(function* () {
561 const current = yield* loadUnlocked;
562 const patch = yield* Effect.try({
563 try: () => transform(current),
564 catch: (cause) => new MobilePreferencesSaveError({ cause }),
565 });
566 const next: Preferences = { ...current, ...patch };
567 const payload = yield* encode(PREFERENCES_KEY, next);
568 yield* saveJson(payload);
569 return next;
570 }),
571 )
572 .pipe(
573 Effect.mapError((cause) =>
574 isMobilePreferencesSaveError(cause) ? cause : new MobilePreferencesSaveError({ cause }),
575 ),
576 ),
577 );
578​
579 return MobilePreferencesStore.of({
580 load,
581 update,
582 savePatch: Effect.fn("MobilePreferencesStore.savePatch")((patch) => update(() => patch)),
583 });
584});
585​
586export const layer = Layer.effect(MobilePreferencesStore, make());