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

639 lines 23.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 * Valid AI live-progress transports — mirrors the TS `AI_TRANSPORTS` constant.
28 *
29 * - `sse` — Server-Sent Events; real-time progress ticks. Requires the host
30 * to allow long-lived `text/event-stream` connections.
31 * - `off` — single request, no progress ticks. Works everywhere; the user
32 * sees "Thinking…" until the final answer.
33 *
34 * Default is `off` because some hosts (locked-down shared environments,
35 * proxies that buffer responses) silently drop SSE mid-stream, which surfaces
36 * to the user as "Lost connection to the assistant".
37 */
38 const DESKTOP_MODE_OS_SETTINGS_AI_TRANSPORTS = array( 'sse', 'off' );
39
40 /**
41 * Built-in AI provider IDs.
42 *
43 * Other providers register themselves via {@see desktop_mode_register_ai_provider()};
44 * sanitization no longer gates the field against this list (the active-provider
45 * resolver does the existence check at lookup time).
46 *
47 * @deprecated 0.18.0 Kept for backwards compatibility; use the provider registry.
48 */
49 const DESKTOP_MODE_OS_SETTINGS_AI_PROVIDERS = array( 'openai' );
50
51 /**
52 * Returns a well-shaped default OS settings array.
53 *
54 * Mirrors the TypeScript `DEFAULTS` constant so a fresh user account
55 * gets the same starting state in both environments.
56 *
57 * @since 0.14.0
58 *
59 * @return array
60 */
61 function desktop_mode_default_os_settings() {
62 return array(
63 'wallpaper' => 'dark',
64 'accent' => 'wp-blue',
65 'dockSize' => 'default',
66 'desktopLayout' => 'classic',
67 'dockRailRenderer' => 'default',
68 'customGradient' => array(
69 'from' => '#2271b1',
70 'to' => '#7c3aed',
71 'angle' => 135,
72 ),
73 'customImage' => null,
74 'libraryHdOnly' => true,
75 'ai' => array(
76 'enabled' => false,
77 'provider' => 'openai',
78 'apiKey' => '', // Legacy field — treated as the OpenAI key for backwards compat.
79 'apiKeys' => array(), // Per-provider keys: { [provider_id]: string }.
80 'transport' => 'off', // Live-progress transport: 'sse' | 'off'. Default off — see DESKTOP_MODE_OS_SETTINGS_AI_TRANSPORTS.
81 ),
82 // Per-user opt-OUT for the native Posts window. When true,
83 // clicking the Posts dock tile opens the `<wpd-table>`-driven
84 // native window instead of the chromeless `edit.php` iframe.
85 // Default ON as of 0.8.0 — the native UI is the canonical
86 // Desktop Mode Posts experience; users can flip it off to fall
87 // back to the classic iframe.
88 // Per-user override of the WordPress Heartbeat interval, in
89 // seconds. 60s matches Core's "idle" default; values below
90 // 15 force a lower `minimalInterval` too. See
91 // `desktop_mode_apply_heartbeat_rate_setting` for the
92 // `heartbeat_settings` filter that applies this.
93 'heartbeatRate' => 60,
94 'nativePostsEnabled' => true,
95 // Per-user list of column keys hidden in the native Posts
96 // window (e.g. array( 'author', 'tags' )). Empty array means
97 // every column is visible. The sticky 'title' column is always
98 // shown — the UI prevents toggling it.
99 'nativePostsHiddenColumns' => array(),
100 // Per-user opt-OUT for the native Pages window. Same posture as
101 // nativePostsEnabled — defaults ON, users can flip off to keep
102 // the classic `edit.php?post_type=page` iframe.
103 'nativePagesEnabled' => true,
104 // Per-user opt-OUT for the native Users window. Defaults ON;
105 // the server-side cap gate (`list_users`) means the toggle
106 // only matters for users who could see the Users tile anyway.
107 'nativeUsersEnabled' => true,
108 // Per-user opt-OUT for the native Plugins window. Defaults ON;
109 // the server-side cap gate (`activate_plugins`) means the
110 // toggle only matters for users who could see the Plugins
111 // tile anyway. When `false`, the dock click falls back to the
112 // classic `plugins.php` chromeless iframe path.
113 'nativePluginsEnabled' => true,
114 // Per-user opt-OUT for the native Comments window. Defaults ON;
115 // the server-side cap gate (`edit_posts`) means the toggle only
116 // matters for users who could see the Comments tile anyway.
117 'nativeCommentsEnabled' => true,
118 // When true, left-clicking the empty wallpaper triggers the
119 // "Show desktop" toggle (macOS-style) and the matching entry is
120 // hidden from the wallpaper context menu. When false (default),
121 // the entry stays in the menu and left clicks on the wallpaper
122 // do nothing. Per-user.
123 'showDesktopOnWallpaperClick' => false,
124 // Diagonal corner ribbon on My WordPress tiles whose post
125 // status isn't `publish` (draft / pending / private /
126 // scheduled). On by default — surfaces unpublished work at
127 // a glance. Per-user.
128 'showPostStatusRibbons' => true,
129 // Per-user opt-OUT for the folder-sharing feature. Defaults
130 // ON. When false:
131 // - The Share button, share-settings modal, "Leave shared
132 // folder" entry, and pending-invite prompt are all
133 // suppressed in the user's shell.
134 // - The heartbeat skips the `shares.pending` payload for
135 // this user so they never see invites land.
136 // - REST share routes return 404 for this user — they
137 // can't list, invite, accept, deny, or leave.
138 // Sites that don't want the feature (solo admin, no
139 // collaborators) can flip the toggle and the surface
140 // disappears without any database changes. The site-wide
141 // "Delete folder sharing data" action in OS Settings →
142 // Features → Advanced is a separate destructive cleanup.
143 'foldersSharingEnabled' => true,
144 // Per-item placement preferences. Map of item id (dock-item
145 // slug or registered desktop-icon id) → one of:
146 // 'both' — show on both dock and desktop.
147 // 'dock' — show only on the dock; hide from desktop.
148 // 'desktop' — show only on the wallpaper; hide from dock.
149 // 'hidden' — hide from every shell surface.
150 // Missing keys mean "no override" — items use their native rail.
151 // Sanitized as map<sanitize_key, enum>. Capped at 256 entries.
152 'itemVisibility' => array(),
153 // Per-user dock ordering. Ordered list of item ids; ids not in
154 // the list keep their server-supplied position appended after
155 // the listed ones. Unknown ids are tolerated.
156 'dockOrder' => array(),
157 // Persisted desktop position for every dock item the user has
158 // promoted to the wallpaper via `itemVisibility[id]=desktop|both`.
159 // Keyed by item id, value is `{ x: int, y: int }`. The JS
160 // synthesizer reads this when building a synthetic placement so
161 // the icon lands where the user last dragged it instead of
162 // resetting to (0, 0) on every reload. Capped at 256 entries.
163 'dockPromotedPositions' => array(),
164 );
165 }
166
167 /**
168 * Retrieves the saved OS settings for a user.
169 *
170 * Always returns a fully-shaped array so the JS side doesn't need to
171 * defend against partial or missing keys.
172 *
173 * @since 0.14.0
174 *
175 * @param int $user_id The user ID.
176 * @return array
177 */
178 function desktop_mode_get_os_settings( $user_id ) {
179 $user_id = (int) $user_id;
180 if ( $user_id <= 0 ) {
181 return desktop_mode_default_os_settings();
182 }
183
184 $raw = get_user_meta( $user_id, DESKTOP_MODE_OS_SETTINGS_META_KEY, true );
185 if ( ! is_array( $raw ) ) {
186 return desktop_mode_default_os_settings();
187 }
188
189 return desktop_mode_sanitize_os_settings( $raw );
190 }
191
192 /**
193 * Saves sanitized OS settings for a user.
194 *
195 * @since 0.14.0
196 *
197 * @param int $user_id The user ID.
198 * @param mixed $settings Raw settings payload from the client.
199 * @return bool True on success, false otherwise.
200 */
201 function desktop_mode_save_os_settings( $user_id, $settings ) {
202 $user_id = (int) $user_id;
203 if ( $user_id <= 0 ) {
204 return false;
205 }
206
207 $clean = desktop_mode_sanitize_os_settings( $settings );
208 return false !== update_user_meta( $user_id, DESKTOP_MODE_OS_SETTINGS_META_KEY, $clean );
209 }
210
211 /**
212 * Sanitizes a raw OS settings payload.
213 *
214 * Unknown keys are ignored; known keys are coerced field-by-field so a
215 * partial save (e.g., only accent changed) merges cleanly with the
216 * defaults rather than wiping unset fields.
217 *
218 * @since 0.14.0
219 *
220 * @param mixed $raw Raw settings from the client or user meta.
221 * @return array Sanitized settings.
222 */
223 function desktop_mode_sanitize_os_settings( $raw ) {
224 $defaults = desktop_mode_default_os_settings();
225
226 if ( ! is_array( $raw ) ) {
227 return $defaults;
228 }
229
230 // Wallpaper — any non-empty string; registry membership is validated
231 // client-side at apply time.
232 $wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] ) && '' !== $raw['wallpaper']
233 ? sanitize_key( $raw['wallpaper'] )
234 : $defaults['wallpaper'];
235
236 // Accent — non-empty string; swatch validity is enforced in the picker.
237 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
238 ? sanitize_key( $raw['accent'] )
239 : $defaults['accent'];
240
241 // Dock size — must be one of the three known values.
242 $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], DESKTOP_MODE_OS_SETTINGS_DOCK_SIZES, true )
243 ? (string) $raw['dockSize']
244 : $defaults['dockSize'];
245
246 // Desktop layout — must be one of the three known values
247 // (`classic`, `unified`, `spatial`). Default `classic`.
248 $desktop_layout = isset( $raw['desktopLayout'] )
249 && in_array( $raw['desktopLayout'], DESKTOP_MODE_OS_SETTINGS_DESKTOP_LAYOUTS, true )
250 ? (string) $raw['desktopLayout']
251 : $defaults['desktopLayout'];
252
253 // Submenu renderer id — accept any sanitize_key()-clean string.
254 // We don't gate on a server-side allow-list because renderers
255 // register from JS at runtime; existence is checked by the
256 // client at resolve time and falls back to `'default'` when
257 // missing.
258 // Dock rail renderer id — accept any sanitize_key()-clean
259 // string. JS-side registry resolves at use time and falls back
260 // to `'default'` when the picked renderer isn't registered.
261 $dock_rail_renderer = $defaults['dockRailRenderer'];
262 if ( isset( $raw['dockRailRenderer'] ) && is_string( $raw['dockRailRenderer'] ) ) {
263 $slug = sanitize_key( $raw['dockRailRenderer'] );
264 if ( '' !== $slug ) {
265 $dock_rail_renderer = $slug;
266 }
267 }
268
269 // Custom gradient — { from, to: valid hex; angle: int 0–360 }.
270 $custom_gradient = $defaults['customGradient'];
271 if ( isset( $raw['customGradient'] ) && is_array( $raw['customGradient'] ) ) {
272 $cg = $raw['customGradient'];
273 if ( isset( $cg['from'] ) && is_string( $cg['from'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['from'] ) ) {
274 $custom_gradient['from'] = strtolower( $cg['from'] );
275 }
276 if ( isset( $cg['to'] ) && is_string( $cg['to'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['to'] ) ) {
277 $custom_gradient['to'] = strtolower( $cg['to'] );
278 }
279 if ( isset( $cg['angle'] ) && is_numeric( $cg['angle'] ) ) {
280 $angle = (int) $cg['angle'];
281 if ( $angle >= 0 && $angle <= 360 ) {
282 $custom_gradient['angle'] = $angle;
283 }
284 }
285 }
286
287 // Custom image — { id: positive int, url: valid https? URL } or null.
288 $custom_image = null;
289 if ( isset( $raw['customImage'] ) && is_array( $raw['customImage'] ) ) {
290 $ci = $raw['customImage'];
291 $ci_id = isset( $ci['id'] ) && is_numeric( $ci['id'] ) ? (int) $ci['id'] : 0;
292 $ci_url = isset( $ci['url'] ) ? esc_url_raw( (string) $ci['url'] ) : '';
293 if ( $ci_id > 0 && '' !== $ci_url && preg_match( '/^https?:\/\//i', $ci_url ) ) {
294 $custom_image = array(
295 'id' => $ci_id,
296 'url' => $ci_url,
297 );
298 }
299 }
300
301 // Library HD only — boolean.
302 $library_hd_only = isset( $raw['libraryHdOnly'] ) ? (bool) $raw['libraryHdOnly'] : $defaults['libraryHdOnly'];
303
304 // AI settings.
305 $ai = $defaults['ai'];
306 if ( isset( $raw['ai'] ) && is_array( $raw['ai'] ) ) {
307 $raw_ai = $raw['ai'];
308
309 if ( isset( $raw_ai['enabled'] ) ) {
310 $ai['enabled'] = (bool) $raw_ai['enabled'];
311 }
312
313 // Provider — accept any sanitize_key()-clean string. We don't gate
314 // on the registry here because providers register on `init` and
315 // sanitize may run earlier (REST boot). Existence is checked at
316 // lookup time by `desktop_mode_ai_get_active_provider_id()`.
317 if ( isset( $raw_ai['provider'] ) && is_string( $raw_ai['provider'] ) ) {
318 $slug = sanitize_key( $raw_ai['provider'] );
319 if ( '' !== $slug ) {
320 $ai['provider'] = $slug;
321 }
322 }
323
324 // API key — strip tags and limit length. The key is opaque to us;
325 // we just store what the user gives. 512 chars is generous for any
326 // real API key while preventing runaway meta writes.
327 if ( isset( $raw_ai['apiKey'] ) && is_string( $raw_ai['apiKey'] ) ) {
328 $ai['apiKey'] = substr( sanitize_text_field( $raw_ai['apiKey'] ), 0, 512 );
329 }
330
331 // Live-progress transport — must be one of the known values.
332 if (
333 isset( $raw_ai['transport'] )
334 && is_string( $raw_ai['transport'] )
335 && in_array( $raw_ai['transport'], DESKTOP_MODE_OS_SETTINGS_AI_TRANSPORTS, true )
336 ) {
337 $ai['transport'] = $raw_ai['transport'];
338 }
339
340 // Per-provider keys map. Limited to 32 entries to bound storage.
341 if ( isset( $raw_ai['apiKeys'] ) && is_array( $raw_ai['apiKeys'] ) ) {
342 $keys = array();
343 foreach ( $raw_ai['apiKeys'] as $pid => $val ) {
344 if ( count( $keys ) >= 32 ) {
345 break;
346 }
347 $slug = sanitize_key( (string) $pid );
348 if ( '' === $slug || ! is_string( $val ) ) {
349 continue;
350 }
351 $keys[ $slug ] = substr( sanitize_text_field( $val ), 0, 512 );
352 }
353 $ai['apiKeys'] = $keys;
354 }
355 }
356
357 // Heartbeat rate — one of the four allowed values. The PHP
358 // filter `desktop_mode_apply_heartbeat_rate_setting` reads
359 // this and passes it through to `heartbeat_settings` so
360 // WordPress Core itself reduces the interval on the next page
361 // load. 5 s is intentionally excluded: Core's
362 // `minimalInterval` floor clamps anything below 15 back up to
363 // 15 unless every upstream filter cooperates, and the gain
364 // over 15 s is marginal.
365 $allowed_heartbeat_rates = array( 15, 30, 45, 60 );
366 $heartbeat_rate = $defaults['heartbeatRate'];
367 if ( isset( $raw['heartbeatRate'] ) && is_numeric( $raw['heartbeatRate'] ) ) {
368 $candidate = (int) $raw['heartbeatRate'];
369 if ( in_array( $candidate, $allowed_heartbeat_rates, true ) ) {
370 $heartbeat_rate = $candidate;
371 }
372 }
373
374 $native_posts_enabled = isset( $raw['nativePostsEnabled'] )
375 ? (bool) $raw['nativePostsEnabled']
376 : $defaults['nativePostsEnabled'];
377
378 $native_posts_hidden_columns = $defaults['nativePostsHiddenColumns'];
379 if ( isset( $raw['nativePostsHiddenColumns'] ) && is_array( $raw['nativePostsHiddenColumns'] ) ) {
380 $native_posts_hidden_columns = array();
381 foreach ( $raw['nativePostsHiddenColumns'] as $col ) {
382 if ( ! is_string( $col ) || '' === $col ) {
383 continue;
384 }
385 $slug = sanitize_key( $col );
386 if ( '' === $slug ) {
387 continue;
388 }
389 $native_posts_hidden_columns[] = $slug;
390 }
391 // Cap to a sane upper bound — far more than any plausible
392 // column count, but blocks a malicious payload from bloating
393 // user meta indefinitely.
394 $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 );
395 }
396
397 $native_pages_enabled = isset( $raw['nativePagesEnabled'] )
398 ? (bool) $raw['nativePagesEnabled']
399 : $defaults['nativePagesEnabled'];
400
401 $native_users_enabled = isset( $raw['nativeUsersEnabled'] )
402 ? (bool) $raw['nativeUsersEnabled']
403 : $defaults['nativeUsersEnabled'];
404
405 $native_plugins_enabled = isset( $raw['nativePluginsEnabled'] )
406 ? (bool) $raw['nativePluginsEnabled']
407 : $defaults['nativePluginsEnabled'];
408
409 $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] )
410 ? (bool) $raw['nativeCommentsEnabled']
411 : $defaults['nativeCommentsEnabled'];
412
413 $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] )
414 ? (bool) $raw['showDesktopOnWallpaperClick']
415 : $defaults['showDesktopOnWallpaperClick'];
416
417 $show_post_status_ribbons = isset( $raw['showPostStatusRibbons'] )
418 ? (bool) $raw['showPostStatusRibbons']
419 : $defaults['showPostStatusRibbons'];
420
421 $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] )
422 ? (bool) $raw['foldersSharingEnabled']
423 : $defaults['foldersSharingEnabled'];
424
425 // itemVisibility — map<sanitize_key, enum>. Unknown ids are kept
426 // (a deactivated plugin's setting should survive reactivation);
427 // invalid placement values are dropped.
428 $item_visibility = array();
429 if ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) {
430 $allowed_placements = array( 'both', 'dock', 'desktop', 'hidden' );
431 $count = 0;
432 foreach ( $raw['itemVisibility'] as $key => $val ) {
433 if ( $count >= 256 ) {
434 break;
435 }
436 if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) {
437 continue;
438 }
439 $slug = sanitize_key( $key );
440 if ( '' === $slug ) {
441 continue;
442 }
443 if ( ! in_array( $val, $allowed_placements, true ) ) {
444 continue;
445 }
446 $item_visibility[ $slug ] = $val;
447 ++$count;
448 }
449 }
450
451 // dockOrder — ordered list of sanitize_key()-clean ids.
452 $dock_order = array();
453 if ( isset( $raw['dockOrder'] ) && is_array( $raw['dockOrder'] ) ) {
454 $seen = array();
455 foreach ( $raw['dockOrder'] as $id ) {
456 if ( ! is_string( $id ) || '' === $id ) {
457 continue;
458 }
459 $slug = sanitize_key( $id );
460 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
461 continue;
462 }
463 $seen[ $slug ] = true;
464 $dock_order[] = $slug;
465 if ( count( $dock_order ) >= 256 ) {
466 break;
467 }
468 }
469 }
470
471 // dockPromotedPositions — map<sanitize_key, {x: int, y: int}>.
472 // Persisted positions for synthetic dock-promoted placements, so
473 // the JS synthesizer can restore the user's manual placement on
474 // next reload. Capped at 256; absurd coordinates are dropped.
475 $dock_promoted_positions = array();
476 if ( isset( $raw['dockPromotedPositions'] ) && is_array( $raw['dockPromotedPositions'] ) ) {
477 $count = 0;
478 $max_coord = 100000; // generous; real screens stop in the thousands.
479 foreach ( $raw['dockPromotedPositions'] as $key => $val ) {
480 if ( $count >= 256 ) {
481 break;
482 }
483 if ( ! is_string( $key ) || '' === $key ) {
484 continue;
485 }
486 $slug = sanitize_key( $key );
487 if ( '' === $slug ) {
488 continue;
489 }
490 if ( ! is_array( $val ) ) {
491 continue;
492 }
493 if ( ! isset( $val['x'] ) || ! isset( $val['y'] ) ) {
494 continue;
495 }
496 $x = is_numeric( $val['x'] ) ? (int) $val['x'] : null;
497 $y = is_numeric( $val['y'] ) ? (int) $val['y'] : null;
498 if ( null === $x || null === $y ) {
499 continue;
500 }
501 if ( abs( $x ) > $max_coord || abs( $y ) > $max_coord ) {
502 continue;
503 }
504 $dock_promoted_positions[ $slug ] = array( 'x' => $x, 'y' => $y );
505 ++$count;
506 }
507 }
508
509 return array(
510 'wallpaper' => $wallpaper,
511 'accent' => $accent,
512 'dockSize' => $dock_size,
513 'desktopLayout' => $desktop_layout,
514 'dockRailRenderer' => $dock_rail_renderer,
515 'customGradient' => $custom_gradient,
516 'customImage' => $custom_image,
517 'libraryHdOnly' => $library_hd_only,
518 'ai' => $ai,
519 'heartbeatRate' => $heartbeat_rate,
520 'nativePostsEnabled' => $native_posts_enabled,
521 'nativePostsHiddenColumns' => $native_posts_hidden_columns,
522 'nativePagesEnabled' => $native_pages_enabled,
523 'nativeUsersEnabled' => $native_users_enabled,
524 'nativePluginsEnabled' => $native_plugins_enabled,
525 'nativeCommentsEnabled' => $native_comments_enabled,
526 'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click,
527 'showPostStatusRibbons' => $show_post_status_ribbons,
528 'foldersSharingEnabled' => $folders_sharing_enabled,
529 'itemVisibility' => $item_visibility,
530 'dockOrder' => $dock_order,
531 'dockPromotedPositions' => $dock_promoted_positions,
532 );
533 }
534
535 /**
536 * Registers the REST routes for OS settings.
537 *
538 * @since 0.14.0
539 */
540 function desktop_mode_register_os_settings_rest_routes() {
541 register_rest_route(
542 'desktop-mode/v1',
543 '/os-settings',
544 array(
545 array(
546 'methods' => WP_REST_Server::READABLE,
547 'callback' => 'desktop_mode_rest_get_os_settings',
548 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
549 ),
550 array(
551 'methods' => WP_REST_Server::CREATABLE,
552 'callback' => 'desktop_mode_rest_save_os_settings',
553 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
554 'args' => array(
555 'settings' => array(
556 'required' => true,
557 'type' => 'object',
558 ),
559 ),
560 ),
561 )
562 );
563 }
564 add_action( 'rest_api_init', 'desktop_mode_register_os_settings_rest_routes' );
565
566 /**
567 * Permission gate for OS settings REST routes.
568 *
569 * @since 0.14.0
570 *
571 * @return bool
572 */
573 function desktop_mode_rest_os_settings_permission() {
574 return is_user_logged_in() && current_user_can( 'read' );
575 }
576
577 /**
578 * GET /desktop-mode/v1/os-settings
579 *
580 * @since 0.14.0
581 *
582 * @return WP_REST_Response
583 */
584 function desktop_mode_rest_get_os_settings() {
585 return rest_ensure_response( desktop_mode_get_os_settings( get_current_user_id() ) );
586 }
587
588 /**
589 * POST /desktop-mode/v1/os-settings
590 *
591 * @since 0.14.0
592 *
593 * @param WP_REST_Request $request The REST request.
594 * @return WP_REST_Response The saved settings (after sanitization).
595 */
596 function desktop_mode_rest_save_os_settings( WP_REST_Request $request ) {
597 $user_id = get_current_user_id();
598 $payload = $request->get_param( 'settings' );
599 desktop_mode_save_os_settings( $user_id, $payload );
600 return rest_ensure_response( desktop_mode_get_os_settings( $user_id ) );
601 }
602
603 /**
604 * Apply the per-user Heartbeat-rate preference to the
605 * `heartbeat_settings` Core filter. WordPress reads these settings
606 * once at page load to size both the initial AJAX interval and the
607 * floor (`minimalInterval`) that prevents JS from speeding things
608 * up. We mirror both so a 5-second rate actually fires every five
609 * seconds (Core's default floor is 15).
610 *
611 * Only applies to users with Desktop Mode enabled — non-desktop
612 * sessions keep Core's defaults. Anonymous requests skip too.
613 *
614 * @since 0.18.0
615 *
616 * @param array $settings Filtered Heartbeat settings.
617 * @return array
618 */
619 function desktop_mode_apply_heartbeat_rate_setting( $settings ) {
620 if ( ! is_array( $settings ) ) {
621 $settings = array();
622 }
623 $user_id = get_current_user_id();
624 if ( $user_id <= 0 ) {
625 return $settings;
626 }
627 if ( function_exists( 'desktop_mode_is_enabled' ) && ! desktop_mode_is_enabled( $user_id ) ) {
628 return $settings;
629 }
630 $os = desktop_mode_get_os_settings( $user_id );
631 $rate = isset( $os['heartbeatRate'] ) ? (int) $os['heartbeatRate'] : 0;
632 if ( ! in_array( $rate, array( 15, 30, 45, 60 ), true ) ) {
633 return $settings;
634 }
635 $settings['interval'] = $rate;
636 return $settings;
637 }
638 add_filter( 'heartbeat_settings', 'desktop_mode_apply_heartbeat_rate_setting' );
639