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

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

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