@sanity/workbench 0.1.0-alpha.2 → 0.1.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/_chunks-es/index.js +39 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/module-federation.js +59 -0
- package/dist/_chunks-es/module-federation.js.map +1 -0
- package/dist/_chunks-es/studio.js +892 -0
- package/dist/_chunks-es/studio.js.map +1 -0
- package/dist/_internal.d.ts +16 -4
- package/dist/_internal.js +34 -27
- package/dist/_internal.js.map +1 -1
- package/dist/core.d.ts +2250 -0
- package/dist/core.js +74 -0
- package/dist/core.js.map +1 -0
- package/dist/system.d.ts +2135 -0
- package/dist/system.js +887 -0
- package/dist/system.js.map +1 -0
- package/package.json +34 -6
- package/src/_exports/core.ts +1 -0
- package/src/_exports/system.ts +1 -0
- package/src/_internal/index.ts +2 -1
- package/src/_internal/render.ts +72 -43
- package/src/core/applications/application-list.ts +104 -0
- package/src/core/applications/application.ts +177 -0
- package/src/core/applications/interface.ts +126 -0
- package/src/core/canvases.ts +92 -0
- package/src/core/config.ts +34 -0
- package/src/core/env.ts +43 -0
- package/src/core/index.ts +13 -0
- package/src/core/log/index.ts +125 -0
- package/src/core/media-libraries.ts +93 -0
- package/src/core/organizations.ts +115 -0
- package/src/core/projects.ts +114 -0
- package/src/core/shared/urls.ts +129 -0
- package/src/core/user-applications/core-app.ts +148 -0
- package/src/core/user-applications/studios/index.ts +3 -0
- package/src/core/user-applications/studios/schemas.ts +128 -0
- package/src/core/user-applications/studios/studio.ts +533 -0
- package/src/core/user-applications/studios/workspace.ts +156 -0
- package/src/core/user-applications/user-application.ts +222 -0
- package/src/system/auth.machine.ts +223 -0
- package/src/system/index.ts +22 -0
- package/src/system/inspect.ts +40 -0
- package/src/system/load-federated-module.ts +53 -0
- package/src/system/module-federation.ts +116 -0
- package/src/system/remote.machine.ts +219 -0
- package/src/system/remotes.machine.ts +92 -0
- package/src/system/root.machine.ts +224 -0
- package/src/system/service.machine.ts +207 -0
- package/src/system/services.machine.ts +120 -0
- package/src/system/system-preferences.machine.ts +215 -0
- package/src/system/telemetry.machine.ts +179 -0
- package/src/_internal/render.test.ts +0 -18
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { assign, fromCallback, setup } from "xstate";
|
|
2
|
+
|
|
3
|
+
import { logger } from "../core/log";
|
|
4
|
+
import { type FederationInstance } from "./module-federation";
|
|
5
|
+
import {
|
|
6
|
+
remoteLogic,
|
|
7
|
+
type RemoteError,
|
|
8
|
+
type ServiceLoaderModule,
|
|
9
|
+
} from "./remote.machine";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export interface ServiceInput {
|
|
15
|
+
/** Stable child key, `${appId}:${serviceName}`. */
|
|
16
|
+
key: string;
|
|
17
|
+
appId: string;
|
|
18
|
+
serviceName: string;
|
|
19
|
+
/** The app's `mf-manifest.json` URL — the federation remote entry. */
|
|
20
|
+
entry: string;
|
|
21
|
+
instance: FederationInstance;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ServiceContext = ServiceInput & {
|
|
25
|
+
/** Resolved worker-bundle URL, absolute on the app origin. */
|
|
26
|
+
workerUrl: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Federation expose segment a service's loader lives under: `services/<name>`. */
|
|
30
|
+
const SERVICES_MODULE = "services";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run the worker. `new Worker(appOriginUrl)` is cross-origin-blocked, and the
|
|
34
|
+
* dev worker isn't self-contained (its root-relative imports would resolve
|
|
35
|
+
* against the wrong origin from a blob). So bootstrap a same-origin worker that
|
|
36
|
+
* dynamically `import()`s the app-origin URL: the worker module then resolves
|
|
37
|
+
* its own imports against the app origin, in dev and build alike. Crashes, a
|
|
38
|
+
* failed load, and the worker's `console.*` (bridged by the wrapper) are
|
|
39
|
+
* surfaced through the host logger (the host owns logging; a worker's own
|
|
40
|
+
* console isn't visible in the page DevTools anyway). Cleanup disposes, then
|
|
41
|
+
* terminates.
|
|
42
|
+
*/
|
|
43
|
+
const runWorker = fromCallback<
|
|
44
|
+
{ type: string },
|
|
45
|
+
{ key: string; serviceName: string; workerUrl: string }
|
|
46
|
+
>(({ input }) => {
|
|
47
|
+
let worker: Worker | undefined;
|
|
48
|
+
let blobUrl: string | undefined;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// A same-origin module worker whose only job is to load the real worker
|
|
52
|
+
// from the app origin, so that worker's imports resolve there too.
|
|
53
|
+
const bootstrap = `import(${JSON.stringify(input.workerUrl)})`;
|
|
54
|
+
blobUrl = URL.createObjectURL(
|
|
55
|
+
new Blob([bootstrap], { type: "text/javascript" }),
|
|
56
|
+
);
|
|
57
|
+
worker = new Worker(blobUrl, { type: "module" });
|
|
58
|
+
|
|
59
|
+
worker.addEventListener("message", (event: MessageEvent) => {
|
|
60
|
+
if (event.data?.kind === "workbench.worker.error") {
|
|
61
|
+
logger.error(
|
|
62
|
+
`Service "${input.key}" worker error`,
|
|
63
|
+
event.data.payload?.message,
|
|
64
|
+
);
|
|
65
|
+
} else if (event.data?.kind === "workbench.worker.log") {
|
|
66
|
+
// Re-emit the worker's `console.*` (bridged by the wrapper) through the
|
|
67
|
+
// host logger, prefixed with the service name so it shows in the page
|
|
68
|
+
// console.
|
|
69
|
+
const { level, message } = event.data.payload ?? {};
|
|
70
|
+
const emit: Record<string, (message: string) => void> = {
|
|
71
|
+
warn: logger.warn,
|
|
72
|
+
error: logger.error,
|
|
73
|
+
debug: logger.debug,
|
|
74
|
+
};
|
|
75
|
+
(emit[level] ?? logger.info)(
|
|
76
|
+
`[service:${input.serviceName}] ${message ?? ""}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// A module-load failure (or any uncaught worker error) fires here on the
|
|
82
|
+
// host side — surface it instead of failing silently.
|
|
83
|
+
worker.addEventListener("error", (event: ErrorEvent) => {
|
|
84
|
+
logger.error(
|
|
85
|
+
`Service "${input.key}" worker error: ${event.message || String(event)}`,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.error(`Service "${input.key}" failed to start its worker`, error);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
try {
|
|
94
|
+
worker?.postMessage({ kind: "workbench.worker.terminate" });
|
|
95
|
+
} finally {
|
|
96
|
+
worker?.terminate();
|
|
97
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Lifecycle machine for a single background service worker.
|
|
104
|
+
*
|
|
105
|
+
* Spawned by the {@link servicesLogic} supervisor, one per `(app, service)`.
|
|
106
|
+
* `loading` loads the worker's loader module through the shared
|
|
107
|
+
* {@link remoteLogic} lifecycle — the same machine every interface (panel, app,
|
|
108
|
+
* worker) loads through — and reads the bundle URL off it. A worker isn't a
|
|
109
|
+
* render module, so it's never handed to an island; `running` bootstraps it as
|
|
110
|
+
* a Web Worker from that URL.
|
|
111
|
+
*
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
export const serviceLogic = setup({
|
|
115
|
+
types: {
|
|
116
|
+
input: {} as ServiceInput,
|
|
117
|
+
context: {} as ServiceContext,
|
|
118
|
+
tags: {} as "loading" | "running" | "failed",
|
|
119
|
+
},
|
|
120
|
+
actors: {
|
|
121
|
+
loadInterface: remoteLogic,
|
|
122
|
+
runWorker,
|
|
123
|
+
},
|
|
124
|
+
}).createMachine({
|
|
125
|
+
id: "service",
|
|
126
|
+
initial: "loading",
|
|
127
|
+
context: ({ input }) => ({ ...input, workerUrl: null }),
|
|
128
|
+
// The invoked loader `sendParent`s a `remote.settled` on settle; this machine
|
|
129
|
+
// reacts via `onSnapshot` instead, so the event is a no-op here.
|
|
130
|
+
on: { "remote.settled": {} },
|
|
131
|
+
states: {
|
|
132
|
+
loading: {
|
|
133
|
+
tags: ["loading"],
|
|
134
|
+
invoke: {
|
|
135
|
+
id: "loader",
|
|
136
|
+
src: "loadInterface",
|
|
137
|
+
input: ({ context }) => ({
|
|
138
|
+
moduleId: `${context.appId}/${SERVICES_MODULE}/${context.serviceName}`,
|
|
139
|
+
entry: context.entry,
|
|
140
|
+
instance: context.instance,
|
|
141
|
+
}),
|
|
142
|
+
onSnapshot: [
|
|
143
|
+
{
|
|
144
|
+
// Loaded and exposing a bundle URL → run it.
|
|
145
|
+
guard: ({ event }) => {
|
|
146
|
+
const module = event.snapshot.context
|
|
147
|
+
.module as ServiceLoaderModule | null;
|
|
148
|
+
return (
|
|
149
|
+
event.snapshot.hasTag("ready") &&
|
|
150
|
+
typeof module?.url === "string"
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
target: "running",
|
|
154
|
+
actions: assign({
|
|
155
|
+
workerUrl: ({ context, event }) => {
|
|
156
|
+
const module = event.snapshot.context
|
|
157
|
+
.module as ServiceLoaderModule;
|
|
158
|
+
// The URL is root-relative to the app; resolve it absolute
|
|
159
|
+
// against the app origin since the loader runs in the host page.
|
|
160
|
+
return new URL(module.url, new URL(context.entry).origin).href;
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
// Load failed, or loaded without a usable URL → no worker.
|
|
166
|
+
guard: ({ event }) => {
|
|
167
|
+
const module = event.snapshot.context
|
|
168
|
+
.module as ServiceLoaderModule | null;
|
|
169
|
+
return (
|
|
170
|
+
event.snapshot.hasTag("failed") ||
|
|
171
|
+
(event.snapshot.hasTag("ready") &&
|
|
172
|
+
typeof module?.url !== "string")
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
target: "error",
|
|
176
|
+
// A worker has no UI surface (unlike a view's error boundary), so a
|
|
177
|
+
// failed load would otherwise be silent — log it through the host.
|
|
178
|
+
actions: ({ context, event }) => {
|
|
179
|
+
const cause = (event.snapshot.context.error as RemoteError | null)
|
|
180
|
+
?.message;
|
|
181
|
+
logger.error(
|
|
182
|
+
`Service "${context.key}" failed to load its worker${
|
|
183
|
+
cause ? `: ${cause}` : ""
|
|
184
|
+
}`,
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
running: {
|
|
192
|
+
tags: ["running"],
|
|
193
|
+
invoke: {
|
|
194
|
+
src: "runWorker",
|
|
195
|
+
input: ({ context }) => ({
|
|
196
|
+
key: context.key,
|
|
197
|
+
serviceName: context.serviceName,
|
|
198
|
+
// `workerUrl` is set on entry to `running` from `loading`'s output.
|
|
199
|
+
workerUrl: context.workerUrl!,
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
error: {
|
|
204
|
+
tags: ["failed"],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type ActorRefFrom, enqueueActions, setup } from "xstate";
|
|
2
|
+
|
|
3
|
+
import { type FederationInstance } from "./module-federation";
|
|
4
|
+
import { serviceLogic } from "./service.machine";
|
|
5
|
+
|
|
6
|
+
/** The shared federation instance, supplied by the root machine. */
|
|
7
|
+
type ServicesInput = {
|
|
8
|
+
instance: FederationInstance;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type ServicesContext = {
|
|
12
|
+
instance: FederationInstance;
|
|
13
|
+
children: Map<string, ActorRefFrom<typeof serviceLogic>>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The full set of background services the workbench should currently be
|
|
18
|
+
* running, re-sent whenever the application list — or any app's declared
|
|
19
|
+
* interfaces — changes. The supervisor reconciles its children against it.
|
|
20
|
+
*/
|
|
21
|
+
type ServicesEvent = {
|
|
22
|
+
type: "services.sync";
|
|
23
|
+
services: ReadonlyArray<{
|
|
24
|
+
appId: string;
|
|
25
|
+
serviceName: string;
|
|
26
|
+
entry: string;
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Stable child key for a service: `${appId}:${serviceName}`. */
|
|
31
|
+
function serviceKey(appId: string, serviceName: string): string {
|
|
32
|
+
return `${appId}:${serviceName}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Supervisor machine for background service workers.
|
|
37
|
+
*
|
|
38
|
+
* Uses the shared {@link FederationInstance} (supplied by the root machine and
|
|
39
|
+
* shared with `remotes`, so an app's remote is registered once for both its
|
|
40
|
+
* views and its workers) and spawns one {@link serviceLogic} child per
|
|
41
|
+
* `(app, service)`. Invoked at the root of the OS machine for inspector
|
|
42
|
+
* visibility — boot does not gate on it.
|
|
43
|
+
*
|
|
44
|
+
* On every `services.sync` it reconciles its children against the desired set:
|
|
45
|
+
* a newly-declared worker is spawned, and a worker whose declaration was
|
|
46
|
+
* removed (e.g. deleted from `sanity.cli.ts` during dev — FR-024) is stopped,
|
|
47
|
+
* which disposes its `runWorker` callback and terminates the Web Worker. A
|
|
48
|
+
* service `src` edit still triggers a Vite full page reload (workers have no
|
|
49
|
+
* HMR boundary), which reboots the workbench and respawns with the new code.
|
|
50
|
+
*
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
export const servicesLogic = setup({
|
|
54
|
+
types: {
|
|
55
|
+
input: {} as ServicesInput,
|
|
56
|
+
context: {} as ServicesContext,
|
|
57
|
+
events: {} as ServicesEvent,
|
|
58
|
+
},
|
|
59
|
+
actors: {
|
|
60
|
+
service: serviceLogic,
|
|
61
|
+
},
|
|
62
|
+
actions: {
|
|
63
|
+
/**
|
|
64
|
+
* Reconcile the running workers against the desired set: spawn the ones not
|
|
65
|
+
* yet running, stop (and forget) the ones no longer declared.
|
|
66
|
+
*/
|
|
67
|
+
syncServices: enqueueActions(({ context, event, enqueue }) => {
|
|
68
|
+
const desired = new Map(
|
|
69
|
+
event.services.map((service) => [
|
|
70
|
+
serviceKey(service.appId, service.serviceName),
|
|
71
|
+
service,
|
|
72
|
+
]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Stop + forget workers whose declaration was removed. Stopping the child
|
|
76
|
+
// disposes its `runWorker` callback, which terminates the Web Worker.
|
|
77
|
+
for (const [key, ref] of context.children) {
|
|
78
|
+
if (!desired.has(key)) enqueue.stopChild(ref);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
enqueue.assign({
|
|
82
|
+
children: ({ context: ctx, spawn }) => {
|
|
83
|
+
const next = new Map<string, ActorRefFrom<typeof serviceLogic>>();
|
|
84
|
+
// Keep the children still declared.
|
|
85
|
+
for (const [key, ref] of ctx.children) {
|
|
86
|
+
if (desired.has(key)) next.set(key, ref);
|
|
87
|
+
}
|
|
88
|
+
// Spawn the newly-declared ones.
|
|
89
|
+
for (const [key, service] of desired) {
|
|
90
|
+
if (next.has(key)) continue;
|
|
91
|
+
next.set(
|
|
92
|
+
key,
|
|
93
|
+
spawn("service", {
|
|
94
|
+
id: key,
|
|
95
|
+
systemId: `services.${key}`,
|
|
96
|
+
input: {
|
|
97
|
+
key,
|
|
98
|
+
appId: service.appId,
|
|
99
|
+
serviceName: service.serviceName,
|
|
100
|
+
entry: service.entry,
|
|
101
|
+
instance: ctx.instance,
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
}).createMachine({
|
|
112
|
+
id: "services",
|
|
113
|
+
context: ({ input }) => ({
|
|
114
|
+
instance: input.instance,
|
|
115
|
+
children: new Map(),
|
|
116
|
+
}),
|
|
117
|
+
on: {
|
|
118
|
+
"services.sync": { actions: "syncServices" },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { assign, fromCallback, sendTo, setup } from "xstate";
|
|
2
|
+
|
|
3
|
+
type ColorScheme = "light" | "dark";
|
|
4
|
+
|
|
5
|
+
type ColorSchemePreference = "system" | ColorScheme;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The system-preferences machine's context. Consumers read fields directly
|
|
9
|
+
* (e.g. via `useSelector`) rather than going through a resolver utility —
|
|
10
|
+
* the action handlers keep `colorScheme` in sync with `preferredColorScheme`
|
|
11
|
+
* and `osColorScheme`.
|
|
12
|
+
*
|
|
13
|
+
* Note: `colorScheme` is intentionally stored as derived state in context so
|
|
14
|
+
* consumers can read the resolved value directly without a utility. The
|
|
15
|
+
* action handlers below are the single source of truth for the resolution.
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export type SystemPreferencesContext = {
|
|
19
|
+
/** User's color scheme choice; `system` defers to the OS preference. */
|
|
20
|
+
preferredColorScheme: ColorSchemePreference;
|
|
21
|
+
/** Last reported OS color scheme. Used to resolve `colorScheme` when the
|
|
22
|
+
* preference is `system`. */
|
|
23
|
+
osColorScheme: ColorScheme;
|
|
24
|
+
/** Resolved color scheme — what the UI should render. Recomputed by the
|
|
25
|
+
* action handlers whenever an input changes. */
|
|
26
|
+
colorScheme: ColorScheme;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PREFERRED_COLOR_SCHEME: ColorSchemePreference = "system";
|
|
30
|
+
const DEFAULT_OS_COLOR_SCHEME: ColorScheme = "light";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Events the system-preferences machine accepts. Both originate from the
|
|
34
|
+
* host's adapter — `preferredColorScheme.set` is also forwarded back to it
|
|
35
|
+
* so it can persist the user's choice.
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export type SystemPreferencesEvent =
|
|
39
|
+
| {
|
|
40
|
+
type: "preferredColorScheme.set";
|
|
41
|
+
preferredColorScheme: ColorSchemePreference;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
type: "osColorScheme.set";
|
|
45
|
+
osColorScheme: ColorScheme;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Adapter interface the host implements to give the system-preferences
|
|
50
|
+
* machine access to the user's environment (OS color scheme, persistent
|
|
51
|
+
* storage, cross-context change notifications).
|
|
52
|
+
*
|
|
53
|
+
* The package owns all orchestration — synchronous seeding, idempotent
|
|
54
|
+
* persistence, mapping cleared storage to `"system"`. The host's
|
|
55
|
+
* implementation is pure DOM/storage glue: read, write, subscribe.
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
export type SystemPreferencesAdapter = {
|
|
59
|
+
/** Read the user's stored preference. `null` means "no preference set"
|
|
60
|
+
* (the machine treats this as `"system"`). */
|
|
61
|
+
getPreferredColorScheme: () => ColorSchemePreference | null;
|
|
62
|
+
/** Write the user's preference to persistent storage. */
|
|
63
|
+
persistPreferredColorScheme: (value: ColorSchemePreference) => void;
|
|
64
|
+
/** Subscribe to cross-context preference changes (e.g. another tab
|
|
65
|
+
* writing to the shared storage). The callback receives the new stored
|
|
66
|
+
* value (or `null` if it was cleared). Returns an unsubscribe function. */
|
|
67
|
+
subscribePreferredColorScheme: (
|
|
68
|
+
callback: (next: ColorSchemePreference | null) => void,
|
|
69
|
+
) => () => void;
|
|
70
|
+
/** Read the current OS-detected color scheme. */
|
|
71
|
+
getOsColorScheme: () => ColorScheme;
|
|
72
|
+
/** Subscribe to OS color-scheme changes (e.g. user flipping dark mode).
|
|
73
|
+
* Returns an unsubscribe function. */
|
|
74
|
+
subscribeOsColorScheme: (callback: (next: ColorScheme) => void) => () => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const resolveColorScheme = (
|
|
78
|
+
preferred: ColorSchemePreference,
|
|
79
|
+
osColorScheme: ColorScheme,
|
|
80
|
+
): ColorScheme => (preferred === "system" ? osColorScheme : preferred);
|
|
81
|
+
|
|
82
|
+
// Internal bridge actor — subscribes to the host's adapter for runtime
|
|
83
|
+
// changes and persists user-driven preference changes when the parent
|
|
84
|
+
// forwards them. No-op if the host didn't pass an adapter (SSR, tests).
|
|
85
|
+
type AdapterBridgeReceiveEvent = Extract<
|
|
86
|
+
SystemPreferencesEvent,
|
|
87
|
+
{ type: "preferredColorScheme.set" }
|
|
88
|
+
>;
|
|
89
|
+
|
|
90
|
+
const adapterBridgeLogic = fromCallback<
|
|
91
|
+
AdapterBridgeReceiveEvent,
|
|
92
|
+
SystemPreferencesAdapter | undefined,
|
|
93
|
+
SystemPreferencesEvent
|
|
94
|
+
>(({ input: adapter, sendBack, receive }) => {
|
|
95
|
+
if (!adapter) return;
|
|
96
|
+
|
|
97
|
+
const unsubscribePreferredColorScheme = adapter.subscribePreferredColorScheme(
|
|
98
|
+
(next) => {
|
|
99
|
+
// Falls back to `"system"` so an external clear (another tab deleting
|
|
100
|
+
// the key) resets the preference rather than silently keeping the
|
|
101
|
+
// previous value.
|
|
102
|
+
sendBack({
|
|
103
|
+
type: "preferredColorScheme.set",
|
|
104
|
+
preferredColorScheme: next ?? "system",
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const unsubscribeOs = adapter.subscribeOsColorScheme((next) => {
|
|
110
|
+
sendBack({ type: "osColorScheme.set", osColorScheme: next });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
receive((event) => {
|
|
114
|
+
// Idempotent: skip writes that match the current stored value so a
|
|
115
|
+
// `preferredColorScheme.set` originating from cross-context change
|
|
116
|
+
// doesn't loop back into another write.
|
|
117
|
+
if (adapter.getPreferredColorScheme() === event.preferredColorScheme)
|
|
118
|
+
return;
|
|
119
|
+
|
|
120
|
+
adapter.persistPreferredColorScheme(event.preferredColorScheme);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return () => {
|
|
124
|
+
unsubscribePreferredColorScheme();
|
|
125
|
+
unsubscribeOs();
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
export const systemPreferencesLogic = setup({
|
|
133
|
+
types: {
|
|
134
|
+
input: {} as { adapter?: SystemPreferencesAdapter },
|
|
135
|
+
context: {} as SystemPreferencesContext & {
|
|
136
|
+
adapter: SystemPreferencesAdapter | undefined;
|
|
137
|
+
},
|
|
138
|
+
events: {} as SystemPreferencesEvent,
|
|
139
|
+
},
|
|
140
|
+
actors: {
|
|
141
|
+
adapterBridge: adapterBridgeLogic,
|
|
142
|
+
},
|
|
143
|
+
actions: {
|
|
144
|
+
setPreferredColorScheme: assign({
|
|
145
|
+
preferredColorScheme: (
|
|
146
|
+
_,
|
|
147
|
+
params: { preferredColorScheme: ColorSchemePreference },
|
|
148
|
+
) => params.preferredColorScheme,
|
|
149
|
+
colorScheme: (
|
|
150
|
+
{ context },
|
|
151
|
+
params: { preferredColorScheme: ColorSchemePreference },
|
|
152
|
+
) =>
|
|
153
|
+
resolveColorScheme(params.preferredColorScheme, context.osColorScheme),
|
|
154
|
+
}),
|
|
155
|
+
setOsColorScheme: assign({
|
|
156
|
+
osColorScheme: (_, params: { osColorScheme: ColorScheme }) =>
|
|
157
|
+
params.osColorScheme,
|
|
158
|
+
colorScheme: ({ context }, params: { osColorScheme: ColorScheme }) =>
|
|
159
|
+
resolveColorScheme(context.preferredColorScheme, params.osColorScheme),
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
}).createMachine({
|
|
163
|
+
id: "system-preferences",
|
|
164
|
+
initial: "ready",
|
|
165
|
+
context: ({ input }) => {
|
|
166
|
+
const adapter = input.adapter;
|
|
167
|
+
const preferredColorScheme =
|
|
168
|
+
adapter?.getPreferredColorScheme() ?? DEFAULT_PREFERRED_COLOR_SCHEME;
|
|
169
|
+
const osColorScheme =
|
|
170
|
+
adapter?.getOsColorScheme() ?? DEFAULT_OS_COLOR_SCHEME;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
adapter,
|
|
174
|
+
preferredColorScheme,
|
|
175
|
+
osColorScheme,
|
|
176
|
+
colorScheme: resolveColorScheme(preferredColorScheme, osColorScheme),
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
invoke: {
|
|
180
|
+
id: "adapter",
|
|
181
|
+
src: "adapterBridge",
|
|
182
|
+
input: ({ context }) => context.adapter,
|
|
183
|
+
},
|
|
184
|
+
states: {
|
|
185
|
+
ready: {
|
|
186
|
+
on: {
|
|
187
|
+
"preferredColorScheme.set": {
|
|
188
|
+
actions: [
|
|
189
|
+
{
|
|
190
|
+
type: "setPreferredColorScheme",
|
|
191
|
+
params: ({ event }) => ({
|
|
192
|
+
preferredColorScheme: event.preferredColorScheme,
|
|
193
|
+
}),
|
|
194
|
+
},
|
|
195
|
+
// Forward to the adapter bridge so it can persist the user's
|
|
196
|
+
// choice. The bridge guards against redundant writes, so
|
|
197
|
+
// round-tripping a `preferredColorScheme.set` it sourced itself
|
|
198
|
+
// (e.g. from a `storage` event in another tab) is a no-op.
|
|
199
|
+
sendTo("adapter", ({ event }) => event),
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
"osColorScheme.set": {
|
|
203
|
+
actions: [
|
|
204
|
+
{
|
|
205
|
+
type: "setOsColorScheme",
|
|
206
|
+
params: ({ event }) => ({
|
|
207
|
+
osColorScheme: event.osColorScheme,
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
});
|