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

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

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