PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.6
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / os-settings.php

os-settings.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.6, at includes/os-settings.php

1,176 lines 47.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — OS Settings Persistence.
4 *
5 * Persists each user's OS Settings preferences (wallpaper, accent color,
6 * dock size, custom gradient/image, HD-only toggle, and AI integration
7 * settings) to user meta so they survive across browsers, devices, and
8 * private/incognito sessions. The JS layer writes to localStorage on
9 * every change for instant read-back, then asynchronously syncs to this
10 * endpoint so user meta is the durable source of truth.
11 *
12 * @package OpenStation
13 */
14
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * User meta key for OS Settings.
19 *
20 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
21 * persisted or externally-visible identifier, so renaming it would
22 * orphan data already written by live installs (or break a live
23 * URL). The mismatch between this constant's name and its value is
24 * deliberate — it is NOT a half-finished rename.
25 */
26 const OPENSTATION_OS_SETTINGS_META_KEY = 'desktop_mode_os_settings';
27
28 /** Valid dock-size IDs — mirrors the TS `DOCK_SIZES` constant. */
29 const OPENSTATION_OS_SETTINGS_DOCK_SIZES = array( 'compact', 'default', 'large' );
30
31 /** Valid window-radius IDs — mirrors the TS `WINDOW_RADII` constant. */
32 const OPENSTATION_OS_SETTINGS_WINDOW_RADII = array( 'sharp', 'default', 'round' );
33
34 /**
35 * Valid admin-bar mode IDs — mirrors the TS `ADMIN_BAR_MODES` constant.
36 *
37 * `static` keeps the WordPress admin bar pinned above the shell (the
38 * default), `dynamic` auto-hides it to a peek strip that reveals on
39 * hover or keyboard focus, and `hidden` removes it entirely.
40 */
41 const OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES = array( 'static', 'dynamic', 'hidden' );
42
43 /** Valid desktop-layout IDs — mirrors the TS `DESKTOP_LAYOUTS` constant. */
44 const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified' );
45
46 /**
47 * Valid dock-placement IDs — mirrors the TS `DOCK_PLACEMENTS` constant.
48 *
49 * Which edge the single dock sits on. Read by the layout dispatcher for
50 * `unified`; `classic` derives its two rails from the layout itself and
51 * ignores this.
52 */
53 const OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS = array( 'bottom', 'left', 'right' );
54
55 /**
56 * Valid dock-behavior IDs — mirrors the TS `DOCK_BEHAVIORS` constant.
57 *
58 * `static` keeps the dock always on screen (the default); `dynamic`
59 * parks it off its edge behind a peek strip that reveals when the
60 * pointer reaches that edge or something on it takes keyboard focus,
61 * and releases the band it floats over from the work area.
62 */
63 const OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS = array( 'static', 'dynamic' );
64
65 /**
66 * `mobileLayout` — which experience the shell renders. `auto`
67 * follows the viewport; the other two force it either way.
68 * Mirrors `OsModePreference` in `src/mode/index.ts`.
69 */
70 const OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS = array( 'auto', 'desktop', 'mobile' );
71
72 /** `mobileTabs` — at most this many ids pinned to the phone tab bar. */
73 const OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX = 3;
74
75 /**
76 * Playable range for the window-reveal duration override, in ms.
77 * Mirrors `MIN_REVEAL_DURATION_MS` / `MAX_REVEAL_DURATION_MS` in
78 * `src/reveals/registry.ts`.
79 *
80 * `0` sits OUTSIDE this range on purpose: it is the "no override"
81 * sentinel, not a duration, and is handled before the clamp.
82 */
83 const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN = 80;
84 const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX = 4000;
85
86 /**
87 * Returns a well-shaped default OS settings array.
88 *
89 * Mirrors the TypeScript `DEFAULTS` constant so a fresh user account
90 * gets the same starting state in both environments.
91 *
92 * @return array
93 */
94 function openstation_default_os_settings() {
95 return array(
96 'wallpaper' => 'galaxy',
97 'accent' => 'pulse',
98 // Only read when `accent` is `custom`. Seeded with Pulse so
99 // picking Custom before touching the wheel is a no-op rather
100 // than a jump to black. Mirrors `DEFAULTS` in
101 // `src/settings/constants.ts`.
102 'customAccent' => '#f252fc',
103 'dockSize' => 'default',
104 // `round` (16px), not the preset id literally named `default`.
105 // Preset ids are stored values and cannot be renamed, so the
106 // option labelled "Default" in the picker is no longer the
107 // shipped default. Must stay in step with `DEFAULTS` in
108 // `src/settings/constants.ts` — PHP seeds the first load and JS
109 // owns every paint after it, so a mismatch shows up as the
110 // corners changing shape a moment after the shell boots.
111 'windowRadius' => 'round',
112 // How the WordPress admin bar presents above the shell.
113 // `hidden` ships as the default so a fresh desktop has ONE
114 // navigation surface: everything the user can open lives on the
115 // dock, and the dock's "Exit OpenStation" tile is the way back
116 // to classic admin. `static` (vanilla behavior) and `dynamic`
117 // are one pick away in OpenStation Preferences → Appearance.
118 'adminBarMode' => 'hidden',
119 // Always on screen. `dynamic` (auto-hide behind a peek strip)
120 // is one pick away in OpenStation Preferences → Appearance.
121 'dockBehavior' => 'static',
122 // The Split layout's sidebar answers for itself: a folded
123 // sidebar over a static bottom dock is a valid desk.
124 'sideDockBehavior' => 'static',
125 // One dock holding every menu, with the system tiles grouped
126 // behind a hairline. `classic` (side bar for core menus + bottom
127 // dock for plugins) is the other option; it is no longer what a
128 // first-run desktop looks like.
129 'desktopLayout' => 'unified',
130 // Which edge the single dock sits on. Ignored by `classic`,
131 // which derives both of its rails from the layout.
132 'dockPlacement' => 'bottom',
133 'dockRailRenderer' => 'default',
134 // Active desktop-theme slug, or `''` for the system default.
135 // Site-wide library (`includes/desktop-themes/`), per-user
136 // activation. Not validated against the installed list here —
137 // the enqueue path checks existence on every request, so a
138 // deleted theme degrades silently instead of needing a
139 // user-meta rewrite.
140 'desktopTheme' => '',
141 // Slugs of the desktop themes whose `recommendedOsSettings`
142 // block has already been applied for this user. A theme's
143 // recommendations are seeded ONCE — the first time the user
144 // activates it — and this list is the record of that. It is
145 // what makes "never overwrite a user's later choices" true:
146 // re-activating a theme they have worn before changes
147 // nothing. The Themes tab's "Apply recommended layout" action
148 // is the deliberate way back. Capped at 64 slugs.
149 'appliedThemeRecommendations' => array(),
150 'unfocusEffect' => 'darken',
151 // Window-reveal id — the clip-path transition that uncovers a
152 // window's content once it finishes loading. Off by default;
153 // `none` is the plain opacity fade the shell has always had.
154 'windowReveal' => 'none',
155 // Global reveal duration override in ms. 0 means "use each
156 // reveal's own tuned timing" — the shipped reveals have
157 // durations chosen per shape (Radar's full turn is slower
158 // than Sweep's straight line), and one flat number would
159 // lose that.
160 'windowRevealDuration' => 0,
161 // Window-link renderer id — how relation ties between windows
162 // are drawn (see includes/window-links.php). `svg-splines` is
163 // the shipped built-in; `none` disables the visuals.
164 'windowLinkRenderer' => 'svg-splines',
165 // When the ties are visible: 'always' (default), 'focus' (only
166 // while a group member is focused), or 'off'.
167 'windowLinkVisibility' => 'always',
168 // Master switch for the window-links feature (OS Settings →
169 // Features). Off unmounts the visuals AND the group behaviors
170 // below; the style knobs above keep their values for when it
171 // comes back on.
172 'windowLinksEnabled' => true,
173 // Focusing a relation-group member raises its related windows
174 // to just below it (silent restack, no focus theft).
175 'windowLinkRaiseOnFocus' => true,
176 // Related windows of the focused member get a subtle outline.
177 'windowLinkHighlight' => true,
178 'customGradient' => array(
179 'from' => '#2271b1',
180 'to' => '#7c3aed',
181 'angle' => 135,
182 ),
183 'customImage' => null,
184 // Per-wallpaper settings bags, keyed by wallpaper id — the
185 // values a wallpaper's `renderConfig` dialog writes (e.g. the
186 // Snow wallpaper's wind / particle count / flake size /
187 // background). Scalar values only; the wallpaper owns the keys'
188 // meaning. Missing ids mean "never configured" — the wallpaper
189 // falls back to its defaults. Capped at 64 wallpapers × 32 keys.
190 'wallpaperSettings' => array(),
191 'libraryHdOnly' => true,
192 'ai' => array(
193 'enabled' => false, // AI assistant is opt-in; enabled from OS Settings → Features once a provider is configured.
194 ),
195 // Per-user opt-IN for the native Posts window. When true,
196 // clicking the Posts dock tile opens the `<os-table>`-driven
197 // native window instead of the chromeless `edit.php` iframe.
198 // Default OFF — the native windows are opt-in
199 // Beta. Fresh installs land on the classic iframe; users turn
200 // this on in OS Settings → Features → Beta features to try it.
201 // Per-user override of the WordPress Heartbeat interval, in
202 // seconds. 60s matches Core's "idle" default; the allowed
203 // rates (15/30/45/60) all sit at or above Core's 15 s
204 // `minimalInterval` floor. See
205 // `openstation_apply_heartbeat_rate_setting` for the
206 // `heartbeat_settings` filter that applies this.
207 'heartbeatRate' => 60,
208 'nativePostsEnabled' => false,
209 // Per-user list of column keys hidden in the native Posts
210 // window (e.g. array( 'author', 'tags' )). Empty array means
211 // every column is visible. The sticky 'title' column is always
212 // shown — the UI prevents toggling it.
213 'nativePostsHiddenColumns' => array(),
214 // Per-user opt-IN for the native Pages window. Same posture as
215 // nativePostsEnabled — defaults OFF (Beta), users opt in to swap
216 // the classic `edit.php?post_type=page` iframe for the native UI.
217 'nativePagesEnabled' => false,
218 // Per-user opt-IN for the native Users window. Defaults OFF
219 // (Beta); the server-side cap gate (`list_users`) means the
220 // toggle only matters for users who could see the Users tile.
221 'nativeUsersEnabled' => false,
222 // Per-user opt-IN for the native Plugins window. Defaults OFF
223 // (Beta); the server-side cap gate (`activate_plugins`) means
224 // the toggle only matters for users who could see the Plugins
225 // tile anyway. When `false`, the dock click uses the classic
226 // `plugins.php` chromeless iframe path.
227 'nativePluginsEnabled' => false,
228 // Per-user opt-IN for the native Comments window. Defaults OFF
229 // (Beta); the server-side cap gate (`edit_posts`) means the
230 // toggle only matters for users who could see the Comments tile.
231 'nativeCommentsEnabled' => false,
232 // Per-user opt-IN for Station Home, the native Dashboard
233 // window. Defaults OFF: the ordinary `index.php` Dashboard
234 // (including any custom dashboard a plugin builds there) opens
235 // as a chromeless iframe until the user opts in via OS
236 // Settings → Features → Beta features.
237 'stationHomeEnabled' => false,
238 // Per-user opt-IN for the service worker's shared admin-asset
239 // cache (Experimental). Defaults OFF. The value feeds the
240 // `openstation_pwa_admin_asset_cache` filter's default via
241 // `openstation_pwa_admin_asset_cache_enabled()` and reaches the
242 // SW inside the served `sw.js` bytes, so a change applies via a
243 // normal SW update on the user's next reload.
244 'adminAssetCacheEnabled' => false,
245 // Per-user opt-IN for hover-intent window prewarming
246 // (Experimental). Defaults OFF. When on, a sustained mouse
247 // hover on a dock tile speculatively builds that page's window
248 // hidden so it appears already rendered on click. Read live by
249 // the dock JS; no server-side behavior attaches to it.
250 'windowPrewarmEnabled' => false,
251 // When true, left-clicking the empty wallpaper triggers the
252 // "Show desktop" toggle (macOS-style) and the matching entry is
253 // hidden from the wallpaper context menu. When false (default),
254 // the entry stays in the menu and left clicks on the wallpaper
255 // do nothing. Per-user.
256 'showDesktopOnWallpaperClick' => false,
257 // Whether the close-all-windows shortcut (Alt+Cmd/Ctrl+W) asks
258 // before it closes. The dialog's "Don't ask again" checkbox is
259 // what writes false; OpenStation Preferences -> Windows is what
260 // turns it back on. Per-user.
261 'confirmCloseAllWindows' => true,
262 // Mio — a soft-body companion that floats over
263 // the wallpaper, settles onto nearby windows, and watches the
264 // pointer. Off by default; toggled from the wallpaper context
265 // menu. Per-user. See `docs/mio.md`.
266 'mioEnabled' => false,
267 // The user's own Mio, as built in "Make it yours": partial
268 // appearance + silhouette overrides, both empty until they
269 // touch a control. Stored per user rather than per browser
270 // because it is a preference about the person — ten minutes
271 // spent building a companion should be waiting on their phone.
272 // Sanitized by `openstation_sanitize_mio_look()`; the ranges
273 // are enforced client-side in `sanitizeMioConfig()`.
274 'mioStyle' => array(
275 'appearance' => array(),
276 'physics' => array(),
277 ),
278 // Diagonal corner ribbon on My WordPress tiles whose post
279 // status isn't `publish` (draft / pending / private /
280 // scheduled). On by default — surfaces unpublished work at
281 // a glance. Per-user.
282 'showPostStatusRibbons' => true,
283 // Unlocks developer-facing surfaces meant for plugin
284 // authors: the Starter Widget appears in the add-widget
285 // picker, the OS Settings → Components tab runs its
286 // intentional missing-import-warner demo, and the Code Blue
287 // error-log reader registers (icon, window, REST routes).
288 // Off by default. Per-user.
289 'developerModeEnabled' => false,
290 // Per-user opt-OUT for the folder-sharing feature. Defaults
291 // ON. When false:
292 // - The Share button, share-settings modal, "Leave shared
293 // folder" entry, and pending-invite prompt are all
294 // suppressed in the user's shell.
295 // - The heartbeat skips the `shares.pending` payload for
296 // this user so they never see invites land.
297 // - REST share routes return 404 for this user — they
298 // can't list, invite, accept, deny, or leave.
299 // Sites that don't want the feature (solo admin, no
300 // collaborators) can flip the toggle and the surface
301 // disappears without any database changes. The site-wide
302 // "Delete folder sharing data" action in OS Settings →
303 // Features → Advanced is a separate destructive cleanup.
304 'foldersSharingEnabled' => true,
305 // Per-item navigation placement. Map of item id → one of:
306 // 'both' — show on a rail and on the desktop.
307 // 'rail' — show only on a rail: the dock, or the sidebar
308 // for a Core admin menu in the split layout.
309 // 'desktop' — show only on the wallpaper.
310 // 'hidden' — hide from every shell surface.
311 // Missing keys mean "no override" — the item takes the default
312 // for its kind, which lives in `src/nav/defaults.ts`.
313 // Sanitized as map<sanitize_key, enum>. Capped at 256 entries.
314 'navPlacement' => array(),
315 // Per-user ordering, flat across every dock/sidebar zone. Ids
316 // not in the list keep their registration order and render
317 // after the listed ones. Unknown ids are tolerated.
318 'navOrder' => array(),
319 // Which experience the shell renders: 'auto' follows the
320 // viewport, 'desktop' / 'mobile' force it. See `includes/mobile.php`.
321 'mobileLayout' => 'auto',
322 // Ids pinned to the phone tab bar, at most three. Empty means
323 // the server default (`openstation_mobile_tab_bar`).
324 'mobileTabs' => array(),
325 // Persisted desktop position for every item the user has
326 // promoted to the wallpaper via `navPlacement[id]=desktop|both`.
327 // Keyed by item id, value is `{ x: int, y: int }`. The JS
328 // synthesizer reads this when building a synthetic placement so
329 // the icon lands where the user last dragged it instead of
330 // resetting to (0, 0) on every reload. Capped at 256 entries.
331 'dockPromotedPositions' => array(),
332 );
333 }
334
335 /**
336 * Retrieves the saved OS settings for a user.
337 *
338 * Always returns a fully-shaped array so the JS side doesn't need to
339 * defend against partial or missing keys.
340 *
341 * @param int $user_id The user ID.
342 * @return array
343 */
344 function openstation_get_os_settings( $user_id ) {
345 $user_id = (int) $user_id;
346 if ( $user_id <= 0 ) {
347 return openstation_default_os_settings();
348 }
349
350 $raw = get_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true );
351 if ( ! is_array( $raw ) ) {
352 return openstation_default_os_settings();
353 }
354
355 return openstation_sanitize_os_settings( $raw );
356 }
357
358 /**
359 * Saves sanitized OS settings for a user.
360 *
361 * @param int $user_id The user ID.
362 * @param mixed $settings Raw settings payload from the client.
363 * @return bool True on success, false otherwise.
364 */
365 function openstation_save_os_settings( $user_id, $settings ) {
366 $user_id = (int) $user_id;
367 if ( $user_id <= 0 ) {
368 return false;
369 }
370
371 $clean = openstation_sanitize_os_settings( $settings );
372 return false !== update_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, $clean );
373 }
374
375 /**
376 * Strip the rail-synthesis prefix an id could carry before the
377 * navigation model.
378 *
379 * `dock:<id>` / `desktop:<id>` used to mean "this tile is a copy of an
380 * item whose real home is the other rail". Nothing synthesizes copies
381 * any more — an item is one item wherever it is painted — so the
382 * prefix is noise, and left in place it would key a preference to an
383 * id nothing registers.
384 *
385 * @param string $id Possibly-prefixed id.
386 * @return string Canonical id.
387 */
388 function openstation_canonical_nav_id( $id ) {
389 $id = (string) $id;
390 if ( 0 === strpos( $id, 'dock:' ) ) {
391 return substr( $id, 5 );
392 }
393 if ( 0 === strpos( $id, 'desktop:' ) ) {
394 return substr( $id, 8 );
395 }
396 return $id;
397 }
398
399 /**
400 * Carry a pre-navigation `itemVisibility` map into `navPlacement`.
401 *
402 * The only value that moves is `'dock'` → `'rail'`: the stored name
403 * is now the REGION rather than a rail, so a Core admin menu the user
404 * kept on a rail follows the layout into the sidebar instead of
405 * needing a second migration the first time they switch.
406 *
407 * Runs on read (see {@see openstation_sanitize_os_settings()}) rather
408 * than as a numbered migration, because OS settings are per-user meta
409 * and a site with many users would pay for a sweep that the next save
410 * performs for free.
411 *
412 * @param array $visibility Legacy map of item id → placement.
413 * @return array Map of canonical item id → nav placement.
414 */
415 function openstation_migrate_item_visibility( $visibility ) {
416 $map = array(
417 'dock' => 'rail',
418 'desktop' => 'desktop',
419 'both' => 'both',
420 'hidden' => 'hidden',
421 );
422
423 $out = array();
424 foreach ( (array) $visibility as $key => $val ) {
425 if ( ! is_string( $key ) || ! is_string( $val ) || ! isset( $map[ $val ] ) ) {
426 continue;
427 }
428 $id = openstation_canonical_nav_id( $key );
429 if ( '' === $id ) {
430 continue;
431 }
432 // A prefixed and an unprefixed key can collapse onto the same
433 // id. The unprefixed one is the item's own preference rather
434 // than a synthesized copy's, so it wins whichever order they
435 // arrive in.
436 if ( $id === $key || ! isset( $out[ $id ] ) ) {
437 $out[ $id ] = $map[ $val ];
438 }
439 }
440 return $out;
441 }
442
443 /**
444 * Sanitizes a raw OS settings payload.
445 *
446 * Unknown keys are ignored; known keys are coerced field-by-field so a
447 * partial save (e.g., only accent changed) merges cleanly with the
448 * defaults rather than wiping unset fields.
449 *
450 * @param mixed $raw Raw settings from the client or user meta.
451 * @return array Sanitized settings.
452 */
453 function openstation_sanitize_os_settings( $raw ) {
454 $defaults = openstation_default_os_settings();
455
456 if ( ! is_array( $raw ) ) {
457 return $defaults;
458 }
459
460 // Wallpaper — any non-empty string; registry membership is validated
461 // client-side at apply time.
462 $wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] ) && '' !== $raw['wallpaper']
463 ? sanitize_key( $raw['wallpaper'] )
464 : $defaults['wallpaper'];
465
466 // Accent — non-empty string; swatch validity is enforced in the picker.
467 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
468 ? sanitize_key( $raw['accent'] )
469 : $defaults['accent'];
470
471 // The colour behind the Custom swatch. A full `#rrggbb` triplet and
472 // nothing else: `sanitize_hex_color()` would also pass `#abc`, which
473 // the client-side parser rejects, and a value that survives the save
474 // only to be dropped on load is worse than one refused here.
475 $custom_accent = isset( $raw['customAccent'] )
476 && is_string( $raw['customAccent'] )
477 && preg_match( '/^#[0-9a-fA-F]{6}$/', $raw['customAccent'] )
478 ? strtolower( $raw['customAccent'] )
479 : $defaults['customAccent'];
480
481 // Dock size — must be one of the three known values.
482 $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], OPENSTATION_OS_SETTINGS_DOCK_SIZES, true )
483 ? (string) $raw['dockSize']
484 : $defaults['dockSize'];
485
486 // Window radius — must be one of the three known values.
487 $window_radius = isset( $raw['windowRadius'] ) && in_array( $raw['windowRadius'], OPENSTATION_OS_SETTINGS_WINDOW_RADII, true )
488 ? (string) $raw['windowRadius']
489 : $defaults['windowRadius'];
490
491 // Admin-bar mode — must be one of the three known values.
492 $admin_bar_mode = isset( $raw['adminBarMode'] )
493 && in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true )
494 ? (string) $raw['adminBarMode']
495 : $defaults['adminBarMode'];
496
497 // Dock behavior — must be one of the two known values. One answer
498 // per rail: the dock, and the Split layout's sidebar.
499 $dock_behavior = isset( $raw['dockBehavior'] )
500 && in_array( $raw['dockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true )
501 ? (string) $raw['dockBehavior']
502 : $defaults['dockBehavior'];
503 $side_dock_behavior = isset( $raw['sideDockBehavior'] )
504 && in_array( $raw['sideDockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true )
505 ? (string) $raw['sideDockBehavior']
506 : $defaults['sideDockBehavior'];
507
508 // Desktop layout — must be one of the known values (`classic`,
509 // `unified`). Default `unified`.
510 $desktop_layout = isset( $raw['desktopLayout'] )
511 && in_array( $raw['desktopLayout'], OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS, true )
512 ? (string) $raw['desktopLayout']
513 : $defaults['desktopLayout'];
514
515 // Dock placement — which edge the single dock sits on. Must be one
516 // of the three known values (`bottom`, `left`, `right`).
517 $dock_placement = isset( $raw['dockPlacement'] )
518 && in_array( $raw['dockPlacement'], OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS, true )
519 ? (string) $raw['dockPlacement']
520 : $defaults['dockPlacement'];
521
522 // Dock rail renderer id — accept any sanitize_key()-clean
523 // string. JS-side registry resolves at use time and falls back
524 // to `'default'` when the picked renderer isn't registered.
525 $dock_rail_renderer = $defaults['dockRailRenderer'];
526 if ( isset( $raw['dockRailRenderer'] ) && is_string( $raw['dockRailRenderer'] ) ) {
527 $slug = sanitize_key( $raw['dockRailRenderer'] );
528 if ( '' !== $slug ) {
529 $dock_rail_renderer = $slug;
530 }
531 }
532
533 // Desktop theme slug — a pattern check, NOT an allow-list, the
534 // same idiom as `dockRailRenderer` above. Validating against the
535 // installed-theme option here would load (and unserialize) that
536 // option on every single settings write for a value the enqueue
537 // path re-checks anyway. `''` is the system default and is a
538 // legitimate value, so an empty/absent key keeps the default.
539 $desktop_theme = $defaults['desktopTheme'];
540 if ( isset( $raw['desktopTheme'] ) && is_string( $raw['desktopTheme'] ) ) {
541 $desktop_theme = sanitize_key( $raw['desktopTheme'] );
542 }
543
544 // appliedThemeRecommendations — list of desktop-theme slugs whose
545 // recommendations this user has already been seeded with. Unknown
546 // slugs are kept (a deleted-then-reinstalled theme must not
547 // re-seed and clobber the settings the user has since chosen).
548 $applied_theme_recommendations = $defaults['appliedThemeRecommendations'];
549 if ( isset( $raw['appliedThemeRecommendations'] ) && is_array( $raw['appliedThemeRecommendations'] ) ) {
550 $applied_theme_recommendations = array();
551 foreach ( $raw['appliedThemeRecommendations'] as $theme_slug ) {
552 if ( ! is_string( $theme_slug ) || '' === $theme_slug ) {
553 continue;
554 }
555 $theme_slug = sanitize_key( $theme_slug );
556 if ( '' === $theme_slug ) {
557 continue;
558 }
559 $applied_theme_recommendations[] = $theme_slug;
560 }
561 // Keep the MOST RECENT 64, not the first 64 — the client
562 // appends, so trimming from the front would silently discard
563 // the entry that was just written and let the theme re-seed on
564 // the next activation.
565 $applied_theme_recommendations = array_slice(
566 array_values( array_unique( $applied_theme_recommendations ) ),
567 -64
568 );
569 }
570
571 // Unfocus effect id — accept the `none` sentinel or any registry id.
572 // Effect ids mirror the JS registry pattern `^[a-z0-9_/-]+$` (slashes
573 // allowed for `vendor/sub-id` namespacing), so we lower-case and strip
574 // to that charset rather than using sanitize_key() (which would drop
575 // the slash and break a namespaced id on round-trip). No allow-list:
576 // the JS engine resolves at use time and treats an unknown id as "no
577 // effect".
578 $unfocus_effect = $defaults['unfocusEffect'];
579 if ( isset( $raw['unfocusEffect'] ) && is_string( $raw['unfocusEffect'] ) ) {
580 $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['unfocusEffect'] ) );
581 if ( '' !== $slug ) {
582 $unfocus_effect = $slug;
583 }
584 }
585
586 // Window-reveal id — same id charset and same no-allow-list
587 // reasoning as the unfocus effect above. The JS surface resolves at
588 // play time and treats an unknown id as "no reveal", so a reveal
589 // belonging to a temporarily-deactivated plugin survives the
590 // round-trip and starts working again the moment it re-registers.
591 $window_reveal = $defaults['windowReveal'];
592 if ( isset( $raw['windowReveal'] ) && is_string( $raw['windowReveal'] ) ) {
593 $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowReveal'] ) );
594 if ( '' !== $slug ) {
595 $window_reveal = $slug;
596 }
597 }
598
599 // Window-reveal duration override — 0 (the default) means "leave
600 // each reveal's own timing alone". Anything else is clamped into
601 // the playable range rather than rejected: a value past the end of
602 // the range still expresses a direction, and the nearest playable
603 // duration is the honest reading of it.
604 $window_reveal_duration = $defaults['windowRevealDuration'];
605 if ( isset( $raw['windowRevealDuration'] ) && is_numeric( $raw['windowRevealDuration'] ) ) {
606 $requested = (int) round( (float) $raw['windowRevealDuration'] );
607 if ( $requested > 0 ) {
608 $window_reveal_duration = max(
609 OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN,
610 min( OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX, $requested )
611 );
612 } else {
613 $window_reveal_duration = 0;
614 }
615 }
616
617 // Window-link renderer id — same id charset as unfocus effects
618 // (slashes allowed for `vendor/sub-id`). No allow-list: the JS
619 // render host resolves at use time and falls back to the built-in
620 // `svg-splines` when the picked renderer isn't registered.
621 $window_link_renderer = $defaults['windowLinkRenderer'];
622 if ( isset( $raw['windowLinkRenderer'] ) && is_string( $raw['windowLinkRenderer'] ) ) {
623 $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowLinkRenderer'] ) );
624 if ( '' !== $slug ) {
625 $window_link_renderer = $slug;
626 }
627 }
628
629 // Window-link visibility — small closed set.
630 $window_link_visibility = $defaults['windowLinkVisibility'];
631 if (
632 isset( $raw['windowLinkVisibility'] )
633 && in_array( $raw['windowLinkVisibility'], array( 'focus', 'always', 'off' ), true )
634 ) {
635 $window_link_visibility = $raw['windowLinkVisibility'];
636 }
637
638 // Window-links feature switches — plain booleans.
639 $window_links_enabled = isset( $raw['windowLinksEnabled'] )
640 ? (bool) $raw['windowLinksEnabled']
641 : $defaults['windowLinksEnabled'];
642
643 $window_link_raise_on_focus = isset( $raw['windowLinkRaiseOnFocus'] )
644 ? (bool) $raw['windowLinkRaiseOnFocus']
645 : $defaults['windowLinkRaiseOnFocus'];
646
647 $window_link_highlight = isset( $raw['windowLinkHighlight'] )
648 ? (bool) $raw['windowLinkHighlight']
649 : $defaults['windowLinkHighlight'];
650
651 // Custom gradient — { from, to: valid hex; angle: int 0–360 }.
652 $custom_gradient = $defaults['customGradient'];
653 if ( isset( $raw['customGradient'] ) && is_array( $raw['customGradient'] ) ) {
654 $cg = $raw['customGradient'];
655 if ( isset( $cg['from'] ) && is_string( $cg['from'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['from'] ) ) {
656 $custom_gradient['from'] = strtolower( $cg['from'] );
657 }
658 if ( isset( $cg['to'] ) && is_string( $cg['to'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['to'] ) ) {
659 $custom_gradient['to'] = strtolower( $cg['to'] );
660 }
661 if ( isset( $cg['angle'] ) && is_numeric( $cg['angle'] ) ) {
662 $angle = (int) $cg['angle'];
663 if ( $angle >= 0 && $angle <= 360 ) {
664 $custom_gradient['angle'] = $angle;
665 }
666 }
667 }
668
669 // Custom image — { id: positive int, url: valid https? URL } or null.
670 $custom_image = null;
671 if ( isset( $raw['customImage'] ) && is_array( $raw['customImage'] ) ) {
672 $ci = $raw['customImage'];
673 $ci_id = isset( $ci['id'] ) && is_numeric( $ci['id'] ) ? (int) $ci['id'] : 0;
674 $ci_url = isset( $ci['url'] ) ? esc_url_raw( (string) $ci['url'] ) : '';
675 if ( $ci_id > 0 && '' !== $ci_url && preg_match( '/^https?:\/\//i', $ci_url ) ) {
676 $custom_image = array(
677 'id' => $ci_id,
678 'url' => $ci_url,
679 );
680 }
681 }
682
683 // wallpaperSettings — map<wallpaper id, map<key, scalar>>. Wallpaper
684 // ids follow the same charset as unfocus-effect ids (slashes allowed
685 // for `vendor/sub-id` namespacing); setting keys follow the JS
686 // identifier-ish charset wallpaper authors use (camelCase, hyphens,
687 // underscores). Values must be scalar — booleans and numbers pass
688 // through typed, strings are sanitized and length-capped. Unknown
689 // wallpaper ids are kept (a deactivated wallpaper plugin's settings
690 // should survive reactivation). Capped at 64 ids × 32 keys.
691 $wallpaper_settings = array();
692 if ( isset( $raw['wallpaperSettings'] ) && is_array( $raw['wallpaperSettings'] ) ) {
693 $id_count = 0;
694 foreach ( $raw['wallpaperSettings'] as $wp_id => $bag ) {
695 if ( $id_count >= 64 ) {
696 break;
697 }
698 if ( ! is_string( $wp_id ) || '' === $wp_id || ! is_array( $bag ) ) {
699 continue;
700 }
701 $wp_slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $wp_id ) );
702 if ( '' === $wp_slug ) {
703 continue;
704 }
705 $clean_bag = array();
706 $key_count = 0;
707 foreach ( $bag as $key => $value ) {
708 if ( $key_count >= 32 ) {
709 break;
710 }
711 if ( ! is_string( $key ) || '' === $key || ! preg_match( '/^[a-zA-Z0-9_-]+$/', $key ) ) {
712 continue;
713 }
714 if ( is_bool( $value ) ) {
715 $clean_bag[ $key ] = $value;
716 } elseif ( is_int( $value ) || is_float( $value ) ) {
717 if ( ! is_finite( (float) $value ) ) {
718 continue;
719 }
720 $clean_bag[ $key ] = $value;
721 } elseif ( is_string( $value ) ) {
722 $clean_bag[ $key ] = mb_substr( sanitize_text_field( $value ), 0, 256 );
723 } else {
724 continue;
725 }
726 ++$key_count;
727 }
728 if ( empty( $clean_bag ) ) {
729 continue;
730 }
731 $wallpaper_settings[ $wp_slug ] = $clean_bag;
732 ++$id_count;
733 }
734 }
735
736 // Library HD only — boolean.
737 $library_hd_only = isset( $raw['libraryHdOnly'] ) ? (bool) $raw['libraryHdOnly'] : $defaults['libraryHdOnly'];
738
739 // AI settings — just the per-user on/off toggle. Provider + model selection
740 // is delegated to the Core AI Client, so there is no preference to persist.
741 $ai = $defaults['ai'];
742 if ( isset( $raw['ai'] ) && is_array( $raw['ai'] ) ) {
743 $raw_ai = $raw['ai'];
744
745 if ( isset( $raw_ai['enabled'] ) ) {
746 $ai['enabled'] = (bool) $raw_ai['enabled'];
747 }
748 }
749
750 // Heartbeat rate — one of the four allowed values. The PHP
751 // filter `openstation_apply_heartbeat_rate_setting` reads
752 // this and passes it through to `heartbeat_settings` so
753 // WordPress Core itself reduces the interval on the next page
754 // load. 5 s is intentionally excluded: Core's
755 // `minimalInterval` floor clamps anything below 15 back up to
756 // 15 unless every upstream filter cooperates, and the gain
757 // over 15 s is marginal.
758 $allowed_heartbeat_rates = array( 15, 30, 45, 60 );
759 $heartbeat_rate = $defaults['heartbeatRate'];
760 if ( isset( $raw['heartbeatRate'] ) && is_numeric( $raw['heartbeatRate'] ) ) {
761 $candidate = (int) $raw['heartbeatRate'];
762 if ( in_array( $candidate, $allowed_heartbeat_rates, true ) ) {
763 $heartbeat_rate = $candidate;
764 }
765 }
766
767 $native_posts_enabled = isset( $raw['nativePostsEnabled'] )
768 ? (bool) $raw['nativePostsEnabled']
769 : $defaults['nativePostsEnabled'];
770
771 $native_posts_hidden_columns = $defaults['nativePostsHiddenColumns'];
772 if ( isset( $raw['nativePostsHiddenColumns'] ) && is_array( $raw['nativePostsHiddenColumns'] ) ) {
773 $native_posts_hidden_columns = array();
774 foreach ( $raw['nativePostsHiddenColumns'] as $col ) {
775 if ( ! is_string( $col ) || '' === $col ) {
776 continue;
777 }
778 $slug = sanitize_key( $col );
779 if ( '' === $slug ) {
780 continue;
781 }
782 $native_posts_hidden_columns[] = $slug;
783 }
784 // Cap to a sane upper bound — far more than any plausible
785 // column count, but blocks a malicious payload from bloating
786 // user meta indefinitely.
787 $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 );
788 }
789
790 $native_pages_enabled = isset( $raw['nativePagesEnabled'] )
791 ? (bool) $raw['nativePagesEnabled']
792 : $defaults['nativePagesEnabled'];
793
794 $native_users_enabled = isset( $raw['nativeUsersEnabled'] )
795 ? (bool) $raw['nativeUsersEnabled']
796 : $defaults['nativeUsersEnabled'];
797
798 $native_plugins_enabled = isset( $raw['nativePluginsEnabled'] )
799 ? (bool) $raw['nativePluginsEnabled']
800 : $defaults['nativePluginsEnabled'];
801
802 $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] )
803 ? (bool) $raw['nativeCommentsEnabled']
804 : $defaults['nativeCommentsEnabled'];
805
806 $station_home_enabled = isset( $raw['stationHomeEnabled'] )
807 ? (bool) $raw['stationHomeEnabled']
808 : $defaults['stationHomeEnabled'];
809
810 $admin_asset_cache_enabled = isset( $raw['adminAssetCacheEnabled'] )
811 ? (bool) $raw['adminAssetCacheEnabled']
812 : $defaults['adminAssetCacheEnabled'];
813
814 $window_prewarm_enabled = isset( $raw['windowPrewarmEnabled'] )
815 ? (bool) $raw['windowPrewarmEnabled']
816 : $defaults['windowPrewarmEnabled'];
817
818 $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] )
819 ? (bool) $raw['showDesktopOnWallpaperClick']
820 : $defaults['showDesktopOnWallpaperClick'];
821
822 $confirm_close_all_windows = isset( $raw['confirmCloseAllWindows'] )
823 ? (bool) $raw['confirmCloseAllWindows']
824 : $defaults['confirmCloseAllWindows'];
825
826 $mio_enabled = isset( $raw['mioEnabled'] )
827 ? (bool) $raw['mioEnabled']
828 : $defaults['mioEnabled'];
829
830 // A missing key means "no look saved yet", which sanitizes to the
831 // same pair of empty arrays the defaults carry — so this needs no
832 // isset() branch of its own.
833 $mio_style = openstation_sanitize_mio_look(
834 isset( $raw['mioStyle'] ) ? $raw['mioStyle'] : null
835 );
836
837 $show_post_status_ribbons = isset( $raw['showPostStatusRibbons'] )
838 ? (bool) $raw['showPostStatusRibbons']
839 : $defaults['showPostStatusRibbons'];
840
841 $developer_mode_enabled = isset( $raw['developerModeEnabled'] )
842 ? (bool) $raw['developerModeEnabled']
843 : $defaults['developerModeEnabled'];
844
845 // mobileLayout — the phone/desktop override.
846 $mobile_layout = isset( $raw['mobileLayout'] )
847 && in_array( $raw['mobileLayout'], OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS, true )
848 ? (string) $raw['mobileLayout']
849 : $defaults['mobileLayout'];
850
851 // mobileTabs — ordered nav ids pinned to the phone tab bar. Same
852 // id grammar as navOrder, capped at the tab bar's slot count.
853 $mobile_tabs = array();
854 if ( isset( $raw['mobileTabs'] ) && is_array( $raw['mobileTabs'] ) ) {
855 $seen_tabs = array();
856 foreach ( $raw['mobileTabs'] as $id ) {
857 if ( ! is_string( $id ) || '' === $id ) {
858 continue;
859 }
860 $slug = sanitize_key( openstation_canonical_nav_id( $id ) );
861 if ( '' === $slug || isset( $seen_tabs[ $slug ] ) ) {
862 continue;
863 }
864 $seen_tabs[ $slug ] = true;
865 $mobile_tabs[] = $slug;
866 if ( count( $mobile_tabs ) >= OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX ) {
867 break;
868 }
869 }
870 }
871
872 $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] )
873 ? (bool) $raw['foldersSharingEnabled']
874 : $defaults['foldersSharingEnabled'];
875
876 // navPlacement — map<sanitize_key, enum>. Unknown ids are kept
877 // (a deactivated plugin's setting should survive reactivation);
878 // invalid placement values are dropped.
879 //
880 // Reads the pre-navigation `itemVisibility` map when this user has
881 // no `navPlacement` yet, so an existing arrangement carries over on
882 // first load and is written back on the next save. See
883 // `openstation_migrate_item_visibility()`.
884 $raw_placement = array();
885 if ( isset( $raw['navPlacement'] ) && is_array( $raw['navPlacement'] ) ) {
886 $raw_placement = $raw['navPlacement'];
887 } elseif ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) {
888 $raw_placement = openstation_migrate_item_visibility( $raw['itemVisibility'] );
889 }
890
891 $nav_placement = array();
892 if ( ! empty( $raw_placement ) ) {
893 $allowed_placements = array( 'both', 'rail', 'desktop', 'hidden' );
894 $count = 0;
895 foreach ( $raw_placement as $key => $val ) {
896 if ( $count >= 256 ) {
897 break;
898 }
899 if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) {
900 continue;
901 }
902 $slug = sanitize_key( $key );
903 if ( '' === $slug ) {
904 continue;
905 }
906 if ( ! in_array( $val, $allowed_placements, true ) ) {
907 continue;
908 }
909 $nav_placement[ $slug ] = $val;
910 ++$count;
911 }
912 }
913
914 // navOrder — ordered list of item ids, flat across every zone.
915 // Reads the pre-navigation `dockOrder` when absent, stripping the
916 // rail-synthesis prefixes (`dock:` / `desktop:`) that model no
917 // longer has.
918 $raw_order = array();
919 if ( isset( $raw['navOrder'] ) && is_array( $raw['navOrder'] ) ) {
920 $raw_order = $raw['navOrder'];
921 } elseif ( isset( $raw['dockOrder'] ) && is_array( $raw['dockOrder'] ) ) {
922 $raw_order = $raw['dockOrder'];
923 }
924
925 $nav_order = array();
926 if ( ! empty( $raw_order ) ) {
927 $seen = array();
928 foreach ( $raw_order as $id ) {
929 if ( ! is_string( $id ) || '' === $id ) {
930 continue;
931 }
932 $slug = sanitize_key( openstation_canonical_nav_id( $id ) );
933 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
934 continue;
935 }
936 $seen[ $slug ] = true;
937 $nav_order[] = $slug;
938 if ( count( $nav_order ) >= 256 ) {
939 break;
940 }
941 }
942 }
943
944 // dockPromotedPositions — map<sanitize_key, {x: int, y: int}>.
945 // Persisted positions for synthetic dock-promoted placements, so
946 // the JS synthesizer can restore the user's manual placement on
947 // next reload. Capped at 256; absurd coordinates are dropped.
948 $dock_promoted_positions = array();
949 if ( isset( $raw['dockPromotedPositions'] ) && is_array( $raw['dockPromotedPositions'] ) ) {
950 $count = 0;
951 $max_coord = 100000; // generous; real screens stop in the thousands.
952 foreach ( $raw['dockPromotedPositions'] as $key => $val ) {
953 if ( $count >= 256 ) {
954 break;
955 }
956 if ( ! is_string( $key ) || '' === $key ) {
957 continue;
958 }
959 $slug = sanitize_key( $key );
960 if ( '' === $slug ) {
961 continue;
962 }
963 if ( ! is_array( $val ) ) {
964 continue;
965 }
966 if ( ! isset( $val['x'] ) || ! isset( $val['y'] ) ) {
967 continue;
968 }
969 $x = is_numeric( $val['x'] ) ? (int) $val['x'] : null;
970 $y = is_numeric( $val['y'] ) ? (int) $val['y'] : null;
971 if ( null === $x || null === $y ) {
972 continue;
973 }
974 if ( abs( $x ) > $max_coord || abs( $y ) > $max_coord ) {
975 continue;
976 }
977 $dock_promoted_positions[ $slug ] = array(
978 'x' => $x,
979 'y' => $y,
980 );
981 ++$count;
982 }
983 }
984
985 return array(
986 'wallpaper' => $wallpaper,
987 'accent' => $accent,
988 'customAccent' => $custom_accent,
989 'dockSize' => $dock_size,
990 'windowRadius' => $window_radius,
991 'adminBarMode' => $admin_bar_mode,
992 'desktopLayout' => $desktop_layout,
993 'dockPlacement' => $dock_placement,
994 'dockBehavior' => $dock_behavior,
995 'sideDockBehavior' => $side_dock_behavior,
996 'dockRailRenderer' => $dock_rail_renderer,
997 'desktopTheme' => $desktop_theme,
998 'appliedThemeRecommendations' => $applied_theme_recommendations,
999 'unfocusEffect' => $unfocus_effect,
1000 'windowReveal' => $window_reveal,
1001 'windowRevealDuration' => $window_reveal_duration,
1002 'windowLinkRenderer' => $window_link_renderer,
1003 'windowLinkVisibility' => $window_link_visibility,
1004 'windowLinksEnabled' => $window_links_enabled,
1005 'windowLinkRaiseOnFocus' => $window_link_raise_on_focus,
1006 'windowLinkHighlight' => $window_link_highlight,
1007 'customGradient' => $custom_gradient,
1008 'customImage' => $custom_image,
1009 'wallpaperSettings' => $wallpaper_settings,
1010 'libraryHdOnly' => $library_hd_only,
1011 'ai' => $ai,
1012 'heartbeatRate' => $heartbeat_rate,
1013 'nativePostsEnabled' => $native_posts_enabled,
1014 'nativePostsHiddenColumns' => $native_posts_hidden_columns,
1015 'nativePagesEnabled' => $native_pages_enabled,
1016 'nativeUsersEnabled' => $native_users_enabled,
1017 'nativePluginsEnabled' => $native_plugins_enabled,
1018 'nativeCommentsEnabled' => $native_comments_enabled,
1019 'stationHomeEnabled' => $station_home_enabled,
1020 'adminAssetCacheEnabled' => $admin_asset_cache_enabled,
1021 'windowPrewarmEnabled' => $window_prewarm_enabled,
1022 'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click,
1023 'confirmCloseAllWindows' => $confirm_close_all_windows,
1024 'mioEnabled' => $mio_enabled,
1025 'mioStyle' => $mio_style,
1026 'showPostStatusRibbons' => $show_post_status_ribbons,
1027 'developerModeEnabled' => $developer_mode_enabled,
1028 'foldersSharingEnabled' => $folders_sharing_enabled,
1029 'navPlacement' => $nav_placement,
1030 'navOrder' => $nav_order,
1031 'mobileLayout' => $mobile_layout,
1032 'mobileTabs' => $mobile_tabs,
1033 'dockPromotedPositions' => $dock_promoted_positions,
1034 );
1035 }
1036
1037 /**
1038 * Registers the REST routes for OS settings.
1039 */
1040 function openstation_register_os_settings_rest_routes() {
1041 register_rest_route(
1042 'desktop-mode/v1',
1043 '/os-settings',
1044 array(
1045 array(
1046 'methods' => WP_REST_Server::READABLE,
1047 'callback' => 'openstation_rest_get_os_settings',
1048 'permission_callback' => 'openstation_rest_os_settings_permission',
1049 ),
1050 array(
1051 'methods' => WP_REST_Server::CREATABLE,
1052 'callback' => 'openstation_rest_save_os_settings',
1053 'permission_callback' => 'openstation_rest_os_settings_permission',
1054 'args' => array(
1055 'settings' => array(
1056 'required' => true,
1057 'type' => 'object',
1058 ),
1059 ),
1060 ),
1061 )
1062 );
1063 }
1064 add_action( 'rest_api_init', 'openstation_register_os_settings_rest_routes' );
1065
1066 /**
1067 * Permission gate for OS settings REST routes.
1068 *
1069 * Requires the caller to be logged in *and* have OpenStation enabled —
1070 * see {@see openstation_rest_require_enabled()} for why `read` alone is
1071 * insufficient.
1072 *
1073 * @return true|WP_Error
1074 */
1075 function openstation_rest_os_settings_permission() {
1076 return openstation_rest_require_enabled();
1077 }
1078
1079 /**
1080 * GET /desktop-mode/v1/os-settings
1081 *
1082 * @return WP_REST_Response
1083 */
1084 function openstation_rest_get_os_settings() {
1085 return rest_ensure_response( openstation_get_os_settings( get_current_user_id() ) );
1086 }
1087
1088 /**
1089 * POST /desktop-mode/v1/os-settings
1090 *
1091 * Accepts a PARTIAL payload: keys the request omits keep the value
1092 * already stored for the user, rather than resetting to the shipped
1093 * default. The client sends only the fields that changed since its
1094 * last confirmed save, which is what stops two open sessions from
1095 * overwriting each other — a session that never touched the
1096 * wallpaper cannot express an opinion about it, so a stale snapshot
1097 * can no longer undo another session's unrelated change.
1098 *
1099 * A full payload still behaves exactly as before: every key is
1100 * present, so every key wins.
1101 *
1102 * The merge lives here rather than in {@see openstation_save_os_settings()}
1103 * on purpose. That function's contract is REPLACE, and migrations
1104 * depend on it: migration 1 in `includes/migrations.php` `unset()`s
1105 * keys and re-saves precisely so the sanitizer backfills the new
1106 * defaults. Give the saver merge semantics and that migration
1107 * silently becomes a no-op.
1108 *
1109 * Merging is shallow, one level deep. For the map-shaped fields
1110 * (`wallpaperSettings`, `navPlacement`, `navOrder`,
1111 * `dockPromotedPositions`) a request that sends the key replaces the
1112 * whole map — deep-merging them would leave no way to delete an
1113 * entry.
1114 *
1115 * @param WP_REST_Request $request The REST request.
1116 * @return WP_REST_Response The saved settings (after sanitization).
1117 */
1118 function openstation_rest_save_os_settings( WP_REST_Request $request ) {
1119 $user_id = get_current_user_id();
1120 $payload = $request->get_param( 'settings' );
1121
1122 // A payload that isn't an object says nothing about any field, so
1123 // it changes nothing. The route declares `'settings' => object`
1124 // and WP's schema validation rejects a scalar before the callback
1125 // runs, so this is unreachable over real REST traffic — but the
1126 // sanitizer resolves a non-array to the full defaults, which
1127 // means the one way to reach this function with a bad payload
1128 // used to be the one way to wipe a user's settings. Returning
1129 // early costs nothing and keeps "don't destroy what wasn't sent"
1130 // true of every path into this handler, not just the ones the
1131 // schema happens to guard.
1132 if ( ! is_array( $payload ) ) {
1133 return rest_ensure_response( openstation_get_os_settings( $user_id ) );
1134 }
1135
1136 openstation_save_os_settings(
1137 $user_id,
1138 array_merge( openstation_get_os_settings( $user_id ), $payload )
1139 );
1140 return rest_ensure_response( openstation_get_os_settings( $user_id ) );
1141 }
1142
1143 /**
1144 * Apply the per-user Heartbeat-rate preference to the
1145 * `heartbeat_settings` Core filter. WordPress reads these settings
1146 * once at page load. We set `interval` only; the allowed rates
1147 * (15/30/45/60 s) all sit at or above Core's 15 s
1148 * `minimalInterval` floor, so the floor never needs overriding.
1149 *
1150 * Only applies to users with OpenStation enabled — non-desktop
1151 * sessions keep Core's defaults. Anonymous requests skip too.
1152 *
1153 * @param array $settings Filtered Heartbeat settings.
1154 * @return array
1155 */
1156 function openstation_apply_heartbeat_rate_setting( $settings ) {
1157 if ( ! is_array( $settings ) ) {
1158 $settings = array();
1159 }
1160 $user_id = get_current_user_id();
1161 if ( $user_id <= 0 ) {
1162 return $settings;
1163 }
1164 if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( $user_id ) ) {
1165 return $settings;
1166 }
1167 $os = openstation_get_os_settings( $user_id );
1168 $rate = isset( $os['heartbeatRate'] ) ? (int) $os['heartbeatRate'] : 0;
1169 if ( ! in_array( $rate, array( 15, 30, 45, 60 ), true ) ) {
1170 return $settings;
1171 }
1172 $settings['interval'] = $rate;
1173 return $settings;
1174 }
1175 add_filter( 'heartbeat_settings', 'openstation_apply_heartbeat_rate_setting' );
1176