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

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

721 lines 27.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — 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 WPDesktopMode
13 */
14
15 defined( 'ABSPATH' ) || exit;
16
17 /** User meta key for OS Settings. */
18 const DESKTOP_MODE_OS_SETTINGS_META_KEY = 'desktop_mode_os_settings';
19
20 /** Valid dock-size IDs — mirrors the TS `DOCK_SIZES` constant. */
21 const DESKTOP_MODE_OS_SETTINGS_DOCK_SIZES = array( 'compact', 'default', 'large' );
22
23 /** Valid desktop-layout IDs — mirrors the TS `DESKTOP_LAYOUTS` constant. */
24 const DESKTOP_MODE_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified', 'spatial' );
25
26 /**
27 * Returns a well-shaped default OS settings array.
28 *
29 * Mirrors the TypeScript `DEFAULTS` constant so a fresh user account
30 * gets the same starting state in both environments.
31 *
32 * @since 0.5.0
33 *
34 * @return array
35 */
36 function desktop_mode_default_os_settings() {
37 return array(
38 'wallpaper' => 'dark',
39 'accent' => 'wp-blue',
40 'dockSize' => 'default',
41 'desktopLayout' => 'classic',
42 'dockRailRenderer' => 'default',
43 'unfocusEffect' => 'darken',
44 // Window-link renderer id — how relation ties between windows
45 // are drawn (see includes/window-links.php). `svg-splines` is
46 // the shipped built-in; `none` disables the visuals.
47 'windowLinkRenderer' => 'svg-splines',
48 // When the ties are visible: 'always' (default), 'focus' (only
49 // while a group member is focused), or 'off'.
50 'windowLinkVisibility' => 'always',
51 // Master switch for the window-links feature (OS Settings →
52 // Features). Off unmounts the visuals AND the group behaviors
53 // below; the style knobs above keep their values for when it
54 // comes back on.
55 'windowLinksEnabled' => true,
56 // Focusing a relation-group member raises its related windows
57 // to just below it (silent restack, no focus theft).
58 'windowLinkRaiseOnFocus' => true,
59 // Related windows of the focused member get a subtle outline.
60 'windowLinkHighlight' => true,
61 'customGradient' => array(
62 'from' => '#2271b1',
63 'to' => '#7c3aed',
64 'angle' => 135,
65 ),
66 'customImage' => null,
67 // Per-wallpaper settings bags, keyed by wallpaper id — the
68 // values a wallpaper's `renderConfig` dialog writes (e.g. the
69 // Snow wallpaper's wind / particle count / flake size /
70 // background). Scalar values only; the wallpaper owns the keys'
71 // meaning. Missing ids mean "never configured" — the wallpaper
72 // falls back to its defaults. Capped at 64 wallpapers × 32 keys.
73 'wallpaperSettings' => array(),
74 'libraryHdOnly' => true,
75 'ai' => array(
76 'enabled' => false, // AI assistant is opt-in; enabled from OS Settings → Features once a provider is configured.
77 ),
78 // Per-user opt-IN for the native Posts window. When true,
79 // clicking the Posts dock tile opens the `<wpd-table>`-driven
80 // native window instead of the chromeless `edit.php` iframe.
81 // Default OFF as of 0.9.1 — the native windows are now opt-in
82 // Beta. Fresh installs land on the classic iframe; users turn
83 // this on in OS Settings → Features → Beta features to try it.
84 // Per-user override of the WordPress Heartbeat interval, in
85 // seconds. 60s matches Core's "idle" default; the allowed
86 // rates (15/30/45/60) all sit at or above Core's 15 s
87 // `minimalInterval` floor. See
88 // `desktop_mode_apply_heartbeat_rate_setting` for the
89 // `heartbeat_settings` filter that applies this.
90 'heartbeatRate' => 60,
91 'nativePostsEnabled' => false,
92 // Per-user list of column keys hidden in the native Posts
93 // window (e.g. array( 'author', 'tags' )). Empty array means
94 // every column is visible. The sticky 'title' column is always
95 // shown — the UI prevents toggling it.
96 'nativePostsHiddenColumns' => array(),
97 // Per-user opt-IN for the native Pages window. Same posture as
98 // nativePostsEnabled — defaults OFF (Beta), users opt in to swap
99 // the classic `edit.php?post_type=page` iframe for the native UI.
100 'nativePagesEnabled' => false,
101 // Per-user opt-IN for the native Users window. Defaults OFF
102 // (Beta); the server-side cap gate (`list_users`) means the
103 // toggle only matters for users who could see the Users tile.
104 'nativeUsersEnabled' => false,
105 // Per-user opt-IN for the native Plugins window. Defaults OFF
106 // (Beta); the server-side cap gate (`activate_plugins`) means
107 // the toggle only matters for users who could see the Plugins
108 // tile anyway. When `false`, the dock click uses the classic
109 // `plugins.php` chromeless iframe path.
110 'nativePluginsEnabled' => false,
111 // Per-user opt-IN for the native Comments window. Defaults OFF
112 // (Beta); the server-side cap gate (`edit_posts`) means the
113 // toggle only matters for users who could see the Comments tile.
114 'nativeCommentsEnabled' => false,
115 // When true, left-clicking the empty wallpaper triggers the
116 // "Show desktop" toggle (macOS-style) and the matching entry is
117 // hidden from the wallpaper context menu. When false (default),
118 // the entry stays in the menu and left clicks on the wallpaper
119 // do nothing. Per-user.
120 'showDesktopOnWallpaperClick' => false,
121 // Diagonal corner ribbon on My WordPress tiles whose post
122 // status isn't `publish` (draft / pending / private /
123 // scheduled). On by default — surfaces unpublished work at
124 // a glance. Per-user.
125 'showPostStatusRibbons' => true,
126 // Unlocks developer-facing surfaces meant for plugin
127 // authors: the Starter Widget appears in the add-widget
128 // picker, and the OS Settings → Components tab runs its
129 // intentional missing-import-warner demo. Off by default.
130 // Per-user.
131 'developerModeEnabled' => false,
132 // Per-user opt-OUT for the folder-sharing feature. Defaults
133 // ON. When false:
134 // - The Share button, share-settings modal, "Leave shared
135 // folder" entry, and pending-invite prompt are all
136 // suppressed in the user's shell.
137 // - The heartbeat skips the `shares.pending` payload for
138 // this user so they never see invites land.
139 // - REST share routes return 404 for this user — they
140 // can't list, invite, accept, deny, or leave.
141 // Sites that don't want the feature (solo admin, no
142 // collaborators) can flip the toggle and the surface
143 // disappears without any database changes. The site-wide
144 // "Delete folder sharing data" action in OS Settings →
145 // Features → Advanced is a separate destructive cleanup.
146 'foldersSharingEnabled' => true,
147 // Per-item placement preferences. Map of item id (dock-item
148 // slug or registered desktop-icon id) → one of:
149 // 'both' — show on both dock and desktop.
150 // 'dock' — show only on the dock; hide from desktop.
151 // 'desktop' — show only on the wallpaper; hide from dock.
152 // 'hidden' — hide from every shell surface.
153 // Missing keys mean "no override" — items use their native rail.
154 // Sanitized as map<sanitize_key, enum>. Capped at 256 entries.
155 'itemVisibility' => array(),
156 // Per-user dock ordering. Ordered list of item ids; ids not in
157 // the list keep their server-supplied position appended after
158 // the listed ones. Unknown ids are tolerated.
159 'dockOrder' => array(),
160 // Persisted desktop position for every dock item the user has
161 // promoted to the wallpaper via `itemVisibility[id]=desktop|both`.
162 // Keyed by item id, value is `{ x: int, y: int }`. The JS
163 // synthesizer reads this when building a synthetic placement so
164 // the icon lands where the user last dragged it instead of
165 // resetting to (0, 0) on every reload. Capped at 256 entries.
166 'dockPromotedPositions' => array(),
167 );
168 }
169
170 /**
171 * Retrieves the saved OS settings for a user.
172 *
173 * Always returns a fully-shaped array so the JS side doesn't need to
174 * defend against partial or missing keys.
175 *
176 * @since 0.5.0
177 *
178 * @param int $user_id The user ID.
179 * @return array
180 */
181 function desktop_mode_get_os_settings( $user_id ) {
182 $user_id = (int) $user_id;
183 if ( $user_id <= 0 ) {
184 return desktop_mode_default_os_settings();
185 }
186
187 $raw = get_user_meta( $user_id, DESKTOP_MODE_OS_SETTINGS_META_KEY, true );
188 if ( ! is_array( $raw ) ) {
189 return desktop_mode_default_os_settings();
190 }
191
192 return desktop_mode_sanitize_os_settings( $raw );
193 }
194
195 /**
196 * Saves sanitized OS settings for a user.
197 *
198 * @since 0.5.0
199 *
200 * @param int $user_id The user ID.
201 * @param mixed $settings Raw settings payload from the client.
202 * @return bool True on success, false otherwise.
203 */
204 function desktop_mode_save_os_settings( $user_id, $settings ) {
205 $user_id = (int) $user_id;
206 if ( $user_id <= 0 ) {
207 return false;
208 }
209
210 $clean = desktop_mode_sanitize_os_settings( $settings );
211 return false !== update_user_meta( $user_id, DESKTOP_MODE_OS_SETTINGS_META_KEY, $clean );
212 }
213
214 /**
215 * Sanitizes a raw OS settings payload.
216 *
217 * Unknown keys are ignored; known keys are coerced field-by-field so a
218 * partial save (e.g., only accent changed) merges cleanly with the
219 * defaults rather than wiping unset fields.
220 *
221 * @since 0.5.0
222 *
223 * @param mixed $raw Raw settings from the client or user meta.
224 * @return array Sanitized settings.
225 */
226 function desktop_mode_sanitize_os_settings( $raw ) {
227 $defaults = desktop_mode_default_os_settings();
228
229 if ( ! is_array( $raw ) ) {
230 return $defaults;
231 }
232
233 // Wallpaper — any non-empty string; registry membership is validated
234 // client-side at apply time.
235 $wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] ) && '' !== $raw['wallpaper']
236 ? sanitize_key( $raw['wallpaper'] )
237 : $defaults['wallpaper'];
238
239 // Accent — non-empty string; swatch validity is enforced in the picker.
240 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
241 ? sanitize_key( $raw['accent'] )
242 : $defaults['accent'];
243
244 // Dock size — must be one of the three known values.
245 $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], DESKTOP_MODE_OS_SETTINGS_DOCK_SIZES, true )
246 ? (string) $raw['dockSize']
247 : $defaults['dockSize'];
248
249 // Desktop layout — must be one of the three known values
250 // (`classic`, `unified`, `spatial`). Default `classic`.
251 $desktop_layout = isset( $raw['desktopLayout'] )
252 && in_array( $raw['desktopLayout'], DESKTOP_MODE_OS_SETTINGS_DESKTOP_LAYOUTS, true )
253 ? (string) $raw['desktopLayout']
254 : $defaults['desktopLayout'];
255
256 // Dock rail renderer id — accept any sanitize_key()-clean
257 // string. JS-side registry resolves at use time and falls back
258 // to `'default'` when the picked renderer isn't registered.
259 $dock_rail_renderer = $defaults['dockRailRenderer'];
260 if ( isset( $raw['dockRailRenderer'] ) && is_string( $raw['dockRailRenderer'] ) ) {
261 $slug = sanitize_key( $raw['dockRailRenderer'] );
262 if ( '' !== $slug ) {
263 $dock_rail_renderer = $slug;
264 }
265 }
266
267 // Unfocus effect id — accept the `none` sentinel or any registry id.
268 // Effect ids mirror the JS registry pattern `^[a-z0-9_/-]+$` (slashes
269 // allowed for `vendor/sub-id` namespacing), so we lower-case and strip
270 // to that charset rather than using sanitize_key() (which would drop
271 // the slash and break a namespaced id on round-trip). No allow-list:
272 // the JS engine resolves at use time and treats an unknown id as "no
273 // effect".
274 $unfocus_effect = $defaults['unfocusEffect'];
275 if ( isset( $raw['unfocusEffect'] ) && is_string( $raw['unfocusEffect'] ) ) {
276 $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['unfocusEffect'] ) );
277 if ( '' !== $slug ) {
278 $unfocus_effect = $slug;
279 }
280 }
281
282 // Window-link renderer id — same id charset as unfocus effects
283 // (slashes allowed for `vendor/sub-id`). No allow-list: the JS
284 // render host resolves at use time and falls back to the built-in
285 // `svg-splines` when the picked renderer isn't registered.
286 $window_link_renderer = $defaults['windowLinkRenderer'];
287 if ( isset( $raw['windowLinkRenderer'] ) && is_string( $raw['windowLinkRenderer'] ) ) {
288 $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowLinkRenderer'] ) );
289 if ( '' !== $slug ) {
290 $window_link_renderer = $slug;
291 }
292 }
293
294 // Window-link visibility — small closed set.
295 $window_link_visibility = $defaults['windowLinkVisibility'];
296 if (
297 isset( $raw['windowLinkVisibility'] )
298 && in_array( $raw['windowLinkVisibility'], array( 'focus', 'always', 'off' ), true )
299 ) {
300 $window_link_visibility = $raw['windowLinkVisibility'];
301 }
302
303 // Window-links feature switches — plain booleans.
304 $window_links_enabled = isset( $raw['windowLinksEnabled'] )
305 ? (bool) $raw['windowLinksEnabled']
306 : $defaults['windowLinksEnabled'];
307
308 $window_link_raise_on_focus = isset( $raw['windowLinkRaiseOnFocus'] )
309 ? (bool) $raw['windowLinkRaiseOnFocus']
310 : $defaults['windowLinkRaiseOnFocus'];
311
312 $window_link_highlight = isset( $raw['windowLinkHighlight'] )
313 ? (bool) $raw['windowLinkHighlight']
314 : $defaults['windowLinkHighlight'];
315
316 // Custom gradient — { from, to: valid hex; angle: int 0–360 }.
317 $custom_gradient = $defaults['customGradient'];
318 if ( isset( $raw['customGradient'] ) && is_array( $raw['customGradient'] ) ) {
319 $cg = $raw['customGradient'];
320 if ( isset( $cg['from'] ) && is_string( $cg['from'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['from'] ) ) {
321 $custom_gradient['from'] = strtolower( $cg['from'] );
322 }
323 if ( isset( $cg['to'] ) && is_string( $cg['to'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['to'] ) ) {
324 $custom_gradient['to'] = strtolower( $cg['to'] );
325 }
326 if ( isset( $cg['angle'] ) && is_numeric( $cg['angle'] ) ) {
327 $angle = (int) $cg['angle'];
328 if ( $angle >= 0 && $angle <= 360 ) {
329 $custom_gradient['angle'] = $angle;
330 }
331 }
332 }
333
334 // Custom image — { id: positive int, url: valid https? URL } or null.
335 $custom_image = null;
336 if ( isset( $raw['customImage'] ) && is_array( $raw['customImage'] ) ) {
337 $ci = $raw['customImage'];
338 $ci_id = isset( $ci['id'] ) && is_numeric( $ci['id'] ) ? (int) $ci['id'] : 0;
339 $ci_url = isset( $ci['url'] ) ? esc_url_raw( (string) $ci['url'] ) : '';
340 if ( $ci_id > 0 && '' !== $ci_url && preg_match( '/^https?:\/\//i', $ci_url ) ) {
341 $custom_image = array(
342 'id' => $ci_id,
343 'url' => $ci_url,
344 );
345 }
346 }
347
348 // wallpaperSettings — map<wallpaper id, map<key, scalar>>. Wallpaper
349 // ids follow the same charset as unfocus-effect ids (slashes allowed
350 // for `vendor/sub-id` namespacing); setting keys follow the JS
351 // identifier-ish charset wallpaper authors use (camelCase, hyphens,
352 // underscores). Values must be scalar — booleans and numbers pass
353 // through typed, strings are sanitized and length-capped. Unknown
354 // wallpaper ids are kept (a deactivated wallpaper plugin's settings
355 // should survive reactivation). Capped at 64 ids × 32 keys.
356 $wallpaper_settings = array();
357 if ( isset( $raw['wallpaperSettings'] ) && is_array( $raw['wallpaperSettings'] ) ) {
358 $id_count = 0;
359 foreach ( $raw['wallpaperSettings'] as $wp_id => $bag ) {
360 if ( $id_count >= 64 ) {
361 break;
362 }
363 if ( ! is_string( $wp_id ) || '' === $wp_id || ! is_array( $bag ) ) {
364 continue;
365 }
366 $wp_slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $wp_id ) );
367 if ( '' === $wp_slug ) {
368 continue;
369 }
370 $clean_bag = array();
371 $key_count = 0;
372 foreach ( $bag as $key => $value ) {
373 if ( $key_count >= 32 ) {
374 break;
375 }
376 if ( ! is_string( $key ) || '' === $key || ! preg_match( '/^[a-zA-Z0-9_-]+$/', $key ) ) {
377 continue;
378 }
379 if ( is_bool( $value ) ) {
380 $clean_bag[ $key ] = $value;
381 } elseif ( is_int( $value ) || is_float( $value ) ) {
382 if ( ! is_finite( (float) $value ) ) {
383 continue;
384 }
385 $clean_bag[ $key ] = $value;
386 } elseif ( is_string( $value ) ) {
387 $clean_bag[ $key ] = mb_substr( sanitize_text_field( $value ), 0, 256 );
388 } else {
389 continue;
390 }
391 ++$key_count;
392 }
393 if ( empty( $clean_bag ) ) {
394 continue;
395 }
396 $wallpaper_settings[ $wp_slug ] = $clean_bag;
397 ++$id_count;
398 }
399 }
400
401 // Library HD only — boolean.
402 $library_hd_only = isset( $raw['libraryHdOnly'] ) ? (bool) $raw['libraryHdOnly'] : $defaults['libraryHdOnly'];
403
404 // AI settings — just the per-user on/off toggle. Provider + model selection
405 // is delegated to the Core AI Client, so there is no preference to persist.
406 $ai = $defaults['ai'];
407 if ( isset( $raw['ai'] ) && is_array( $raw['ai'] ) ) {
408 $raw_ai = $raw['ai'];
409
410 if ( isset( $raw_ai['enabled'] ) ) {
411 $ai['enabled'] = (bool) $raw_ai['enabled'];
412 }
413 }
414
415 // Heartbeat rate — one of the four allowed values. The PHP
416 // filter `desktop_mode_apply_heartbeat_rate_setting` reads
417 // this and passes it through to `heartbeat_settings` so
418 // WordPress Core itself reduces the interval on the next page
419 // load. 5 s is intentionally excluded: Core's
420 // `minimalInterval` floor clamps anything below 15 back up to
421 // 15 unless every upstream filter cooperates, and the gain
422 // over 15 s is marginal.
423 $allowed_heartbeat_rates = array( 15, 30, 45, 60 );
424 $heartbeat_rate = $defaults['heartbeatRate'];
425 if ( isset( $raw['heartbeatRate'] ) && is_numeric( $raw['heartbeatRate'] ) ) {
426 $candidate = (int) $raw['heartbeatRate'];
427 if ( in_array( $candidate, $allowed_heartbeat_rates, true ) ) {
428 $heartbeat_rate = $candidate;
429 }
430 }
431
432 $native_posts_enabled = isset( $raw['nativePostsEnabled'] )
433 ? (bool) $raw['nativePostsEnabled']
434 : $defaults['nativePostsEnabled'];
435
436 $native_posts_hidden_columns = $defaults['nativePostsHiddenColumns'];
437 if ( isset( $raw['nativePostsHiddenColumns'] ) && is_array( $raw['nativePostsHiddenColumns'] ) ) {
438 $native_posts_hidden_columns = array();
439 foreach ( $raw['nativePostsHiddenColumns'] as $col ) {
440 if ( ! is_string( $col ) || '' === $col ) {
441 continue;
442 }
443 $slug = sanitize_key( $col );
444 if ( '' === $slug ) {
445 continue;
446 }
447 $native_posts_hidden_columns[] = $slug;
448 }
449 // Cap to a sane upper bound — far more than any plausible
450 // column count, but blocks a malicious payload from bloating
451 // user meta indefinitely.
452 $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 );
453 }
454
455 $native_pages_enabled = isset( $raw['nativePagesEnabled'] )
456 ? (bool) $raw['nativePagesEnabled']
457 : $defaults['nativePagesEnabled'];
458
459 $native_users_enabled = isset( $raw['nativeUsersEnabled'] )
460 ? (bool) $raw['nativeUsersEnabled']
461 : $defaults['nativeUsersEnabled'];
462
463 $native_plugins_enabled = isset( $raw['nativePluginsEnabled'] )
464 ? (bool) $raw['nativePluginsEnabled']
465 : $defaults['nativePluginsEnabled'];
466
467 $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] )
468 ? (bool) $raw['nativeCommentsEnabled']
469 : $defaults['nativeCommentsEnabled'];
470
471 $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] )
472 ? (bool) $raw['showDesktopOnWallpaperClick']
473 : $defaults['showDesktopOnWallpaperClick'];
474
475 $show_post_status_ribbons = isset( $raw['showPostStatusRibbons'] )
476 ? (bool) $raw['showPostStatusRibbons']
477 : $defaults['showPostStatusRibbons'];
478
479 $developer_mode_enabled = isset( $raw['developerModeEnabled'] )
480 ? (bool) $raw['developerModeEnabled']
481 : $defaults['developerModeEnabled'];
482
483 $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] )
484 ? (bool) $raw['foldersSharingEnabled']
485 : $defaults['foldersSharingEnabled'];
486
487 // itemVisibility — map<sanitize_key, enum>. Unknown ids are kept
488 // (a deactivated plugin's setting should survive reactivation);
489 // invalid placement values are dropped.
490 $item_visibility = array();
491 if ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) {
492 $allowed_placements = array( 'both', 'dock', 'desktop', 'hidden' );
493 $count = 0;
494 foreach ( $raw['itemVisibility'] as $key => $val ) {
495 if ( $count >= 256 ) {
496 break;
497 }
498 if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) {
499 continue;
500 }
501 $slug = sanitize_key( $key );
502 if ( '' === $slug ) {
503 continue;
504 }
505 if ( ! in_array( $val, $allowed_placements, true ) ) {
506 continue;
507 }
508 $item_visibility[ $slug ] = $val;
509 ++$count;
510 }
511 }
512
513 // dockOrder — ordered list of item ids. Most are sanitize_key()-
514 // clean dock slugs, but cross-rail tiles the user promoted carry a
515 // rail-synthesis prefix (`desktop:<id>` / `dock:<id>`, built by
516 // src/settings/item-placement.ts). sanitize_key() strips the colon,
517 // which silently breaks the JS order match on reload and can collide
518 // with an unrelated id — so allow the colon (and hyphen/underscore)
519 // while still rejecting anything outside the JS id charset.
520 $dock_order = array();
521 if ( isset( $raw['dockOrder'] ) && is_array( $raw['dockOrder'] ) ) {
522 $seen = array();
523 foreach ( $raw['dockOrder'] as $id ) {
524 if ( ! is_string( $id ) || '' === $id ) {
525 continue;
526 }
527 $slug = (string) preg_replace( '/[^a-z0-9_:-]+/', '', strtolower( $id ) );
528 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
529 continue;
530 }
531 $seen[ $slug ] = true;
532 $dock_order[] = $slug;
533 if ( count( $dock_order ) >= 256 ) {
534 break;
535 }
536 }
537 }
538
539 // dockPromotedPositions — map<sanitize_key, {x: int, y: int}>.
540 // Persisted positions for synthetic dock-promoted placements, so
541 // the JS synthesizer can restore the user's manual placement on
542 // next reload. Capped at 256; absurd coordinates are dropped.
543 $dock_promoted_positions = array();
544 if ( isset( $raw['dockPromotedPositions'] ) && is_array( $raw['dockPromotedPositions'] ) ) {
545 $count = 0;
546 $max_coord = 100000; // generous; real screens stop in the thousands.
547 foreach ( $raw['dockPromotedPositions'] as $key => $val ) {
548 if ( $count >= 256 ) {
549 break;
550 }
551 if ( ! is_string( $key ) || '' === $key ) {
552 continue;
553 }
554 $slug = sanitize_key( $key );
555 if ( '' === $slug ) {
556 continue;
557 }
558 if ( ! is_array( $val ) ) {
559 continue;
560 }
561 if ( ! isset( $val['x'] ) || ! isset( $val['y'] ) ) {
562 continue;
563 }
564 $x = is_numeric( $val['x'] ) ? (int) $val['x'] : null;
565 $y = is_numeric( $val['y'] ) ? (int) $val['y'] : null;
566 if ( null === $x || null === $y ) {
567 continue;
568 }
569 if ( abs( $x ) > $max_coord || abs( $y ) > $max_coord ) {
570 continue;
571 }
572 $dock_promoted_positions[ $slug ] = array(
573 'x' => $x,
574 'y' => $y,
575 );
576 ++$count;
577 }
578 }
579
580 return array(
581 'wallpaper' => $wallpaper,
582 'accent' => $accent,
583 'dockSize' => $dock_size,
584 'desktopLayout' => $desktop_layout,
585 'dockRailRenderer' => $dock_rail_renderer,
586 'unfocusEffect' => $unfocus_effect,
587 'windowLinkRenderer' => $window_link_renderer,
588 'windowLinkVisibility' => $window_link_visibility,
589 'windowLinksEnabled' => $window_links_enabled,
590 'windowLinkRaiseOnFocus' => $window_link_raise_on_focus,
591 'windowLinkHighlight' => $window_link_highlight,
592 'customGradient' => $custom_gradient,
593 'customImage' => $custom_image,
594 'wallpaperSettings' => $wallpaper_settings,
595 'libraryHdOnly' => $library_hd_only,
596 'ai' => $ai,
597 'heartbeatRate' => $heartbeat_rate,
598 'nativePostsEnabled' => $native_posts_enabled,
599 'nativePostsHiddenColumns' => $native_posts_hidden_columns,
600 'nativePagesEnabled' => $native_pages_enabled,
601 'nativeUsersEnabled' => $native_users_enabled,
602 'nativePluginsEnabled' => $native_plugins_enabled,
603 'nativeCommentsEnabled' => $native_comments_enabled,
604 'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click,
605 'showPostStatusRibbons' => $show_post_status_ribbons,
606 'developerModeEnabled' => $developer_mode_enabled,
607 'foldersSharingEnabled' => $folders_sharing_enabled,
608 'itemVisibility' => $item_visibility,
609 'dockOrder' => $dock_order,
610 'dockPromotedPositions' => $dock_promoted_positions,
611 );
612 }
613
614 /**
615 * Registers the REST routes for OS settings.
616 *
617 * @since 0.5.0
618 */
619 function desktop_mode_register_os_settings_rest_routes() {
620 register_rest_route(
621 'desktop-mode/v1',
622 '/os-settings',
623 array(
624 array(
625 'methods' => WP_REST_Server::READABLE,
626 'callback' => 'desktop_mode_rest_get_os_settings',
627 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
628 ),
629 array(
630 'methods' => WP_REST_Server::CREATABLE,
631 'callback' => 'desktop_mode_rest_save_os_settings',
632 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
633 'args' => array(
634 'settings' => array(
635 'required' => true,
636 'type' => 'object',
637 ),
638 ),
639 ),
640 )
641 );
642 }
643 add_action( 'rest_api_init', 'desktop_mode_register_os_settings_rest_routes' );
644
645 /**
646 * Permission gate for OS settings REST routes.
647 *
648 * Requires the caller to be logged in *and* have desktop mode enabled —
649 * see {@see desktop_mode_rest_require_enabled()} for why `read` alone is
650 * insufficient.
651 *
652 * @since 0.8.10 Hardened to require desktop mode enabled (was `read`).
653 *
654 * @return true|WP_Error
655 */
656 function desktop_mode_rest_os_settings_permission() {
657 return desktop_mode_rest_require_enabled();
658 }
659
660 /**
661 * GET /desktop-mode/v1/os-settings
662 *
663 * @since 0.5.0
664 *
665 * @return WP_REST_Response
666 */
667 function desktop_mode_rest_get_os_settings() {
668 return rest_ensure_response( desktop_mode_get_os_settings( get_current_user_id() ) );
669 }
670
671 /**
672 * POST /desktop-mode/v1/os-settings
673 *
674 * @since 0.5.0
675 *
676 * @param WP_REST_Request $request The REST request.
677 * @return WP_REST_Response The saved settings (after sanitization).
678 */
679 function desktop_mode_rest_save_os_settings( WP_REST_Request $request ) {
680 $user_id = get_current_user_id();
681 $payload = $request->get_param( 'settings' );
682 desktop_mode_save_os_settings( $user_id, $payload );
683 return rest_ensure_response( desktop_mode_get_os_settings( $user_id ) );
684 }
685
686 /**
687 * Apply the per-user Heartbeat-rate preference to the
688 * `heartbeat_settings` Core filter. WordPress reads these settings
689 * once at page load. We set `interval` only; the allowed rates
690 * (15/30/45/60 s) all sit at or above Core's 15 s
691 * `minimalInterval` floor, so the floor never needs overriding.
692 *
693 * Only applies to users with Desktop Mode enabled — non-desktop
694 * sessions keep Core's defaults. Anonymous requests skip too.
695 *
696 * @since 0.8.5
697 *
698 * @param array $settings Filtered Heartbeat settings.
699 * @return array
700 */
701 function desktop_mode_apply_heartbeat_rate_setting( $settings ) {
702 if ( ! is_array( $settings ) ) {
703 $settings = array();
704 }
705 $user_id = get_current_user_id();
706 if ( $user_id <= 0 ) {
707 return $settings;
708 }
709 if ( function_exists( 'desktop_mode_is_enabled' ) && ! desktop_mode_is_enabled( $user_id ) ) {
710 return $settings;
711 }
712 $os = desktop_mode_get_os_settings( $user_id );
713 $rate = isset( $os['heartbeatRate'] ) ? (int) $os['heartbeatRate'] : 0;
714 if ( ! in_array( $rate, array( 15, 30, 45, 60 ), true ) ) {
715 return $settings;
716 }
717 $settings['interval'] = $rate;
718 return $settings;
719 }
720 add_filter( 'heartbeat_settings', 'desktop_mode_apply_heartbeat_rate_setting' );
721