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

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