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

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