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