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

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