| 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 |
|