PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.5
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.5, at includes/os-settings.php

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