| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — 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 OpenStation |
| 13 |
*/ |
| 14 |
|
| 15 |
defined( 'ABSPATH' ) || exit; |
| 16 |
|
| 17 |
/** |
| 18 |
* User meta key for OS Settings. |
| 19 |
* |
| 20 |
* The VALUE keeps its pre-rebrand spelling on purpose: it is a |
| 21 |
* persisted or externally-visible identifier, so renaming it would |
| 22 |
* orphan data already written by live installs (or break a live |
| 23 |
* URL). The mismatch between this constant's name and its value is |
| 24 |
* deliberate — it is NOT a half-finished rename. |
| 25 |
*/ |
| 26 |
const OPENSTATION_OS_SETTINGS_META_KEY = 'desktop_mode_os_settings'; |
| 27 |
|
| 28 |
/** Valid dock-size IDs — mirrors the TS `DOCK_SIZES` constant. */ |
| 29 |
const OPENSTATION_OS_SETTINGS_DOCK_SIZES = array( 'compact', 'default', 'large' ); |
| 30 |
|
| 31 |
/** Valid window-radius IDs — mirrors the TS `WINDOW_RADII` constant. */ |
| 32 |
const OPENSTATION_OS_SETTINGS_WINDOW_RADII = array( 'sharp', 'default', 'round' ); |
| 33 |
|
| 34 |
/** |
| 35 |
* Valid admin-bar mode IDs — mirrors the TS `ADMIN_BAR_MODES` constant. |
| 36 |
* |
| 37 |
* `static` keeps the WordPress admin bar pinned above the shell (the |
| 38 |
* default), `dynamic` auto-hides it to a peek strip that reveals on |
| 39 |
* hover or keyboard focus, and `hidden` removes it entirely. |
| 40 |
*/ |
| 41 |
const OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES = array( 'static', 'dynamic', 'hidden' ); |
| 42 |
|
| 43 |
/** Valid desktop-layout IDs — mirrors the TS `DESKTOP_LAYOUTS` constant. */ |
| 44 |
const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified' ); |
| 45 |
|
| 46 |
/** |
| 47 |
* Valid dock-placement IDs — mirrors the TS `DOCK_PLACEMENTS` constant. |
| 48 |
* |
| 49 |
* Which edge the single dock sits on. Read by the layout dispatcher for |
| 50 |
* `unified`; `classic` derives its two rails from the layout itself and |
| 51 |
* ignores this. |
| 52 |
*/ |
| 53 |
const OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS = array( 'bottom', 'left', 'right' ); |
| 54 |
|
| 55 |
/** |
| 56 |
* Valid dock-behavior IDs — mirrors the TS `DOCK_BEHAVIORS` constant. |
| 57 |
* |
| 58 |
* `static` keeps the dock always on screen (the default); `dynamic` |
| 59 |
* parks it off its edge behind a peek strip that reveals when the |
| 60 |
* pointer reaches that edge or something on it takes keyboard focus, |
| 61 |
* and releases the band it floats over from the work area. |
| 62 |
*/ |
| 63 |
const OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS = array( 'static', 'dynamic' ); |
| 64 |
|
| 65 |
/** |
| 66 |
* `mobileLayout` — which experience the shell renders. `auto` |
| 67 |
* follows the viewport; the other two force it either way. |
| 68 |
* Mirrors `OsModePreference` in `src/mode/index.ts`. |
| 69 |
*/ |
| 70 |
const OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS = array( 'auto', 'desktop', 'mobile' ); |
| 71 |
|
| 72 |
/** `mobileTabs` — at most this many ids pinned to the phone tab bar. */ |
| 73 |
const OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX = 3; |
| 74 |
|
| 75 |
/** |
| 76 |
* Playable range for the window-reveal duration override, in ms. |
| 77 |
* Mirrors `MIN_REVEAL_DURATION_MS` / `MAX_REVEAL_DURATION_MS` in |
| 78 |
* `src/reveals/registry.ts`. |
| 79 |
* |
| 80 |
* `0` sits OUTSIDE this range on purpose: it is the "no override" |
| 81 |
* sentinel, not a duration, and is handled before the clamp. |
| 82 |
*/ |
| 83 |
const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN = 80; |
| 84 |
const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX = 4000; |
| 85 |
|
| 86 |
/** |
| 87 |
* Returns a well-shaped default OS settings array. |
| 88 |
* |
| 89 |
* Mirrors the TypeScript `DEFAULTS` constant so a fresh user account |
| 90 |
* gets the same starting state in both environments. |
| 91 |
* |
| 92 |
* @return array |
| 93 |
*/ |
| 94 |
function openstation_default_os_settings() { |
| 95 |
return array( |
| 96 |
'wallpaper' => 'galaxy', |
| 97 |
'accent' => 'pulse', |
| 98 |
// Only read when `accent` is `custom`. Seeded with Pulse so |
| 99 |
// picking Custom before touching the wheel is a no-op rather |
| 100 |
// than a jump to black. Mirrors `DEFAULTS` in |
| 101 |
// `src/settings/constants.ts`. |
| 102 |
'customAccent' => '#f252fc', |
| 103 |
'dockSize' => 'default', |
| 104 |
// `round` (16px), not the preset id literally named `default`. |
| 105 |
// Preset ids are stored values and cannot be renamed, so the |
| 106 |
// option labelled "Default" in the picker is no longer the |
| 107 |
// shipped default. Must stay in step with `DEFAULTS` in |
| 108 |
// `src/settings/constants.ts` — PHP seeds the first load and JS |
| 109 |
// owns every paint after it, so a mismatch shows up as the |
| 110 |
// corners changing shape a moment after the shell boots. |
| 111 |
'windowRadius' => 'round', |
| 112 |
// How the WordPress admin bar presents above the shell. |
| 113 |
// `hidden` ships as the default so a fresh desktop has ONE |
| 114 |
// navigation surface: everything the user can open lives on the |
| 115 |
// dock, and the dock's "Exit OpenStation" tile is the way back |
| 116 |
// to classic admin. `static` (vanilla behavior) and `dynamic` |
| 117 |
// are one pick away in OpenStation Preferences → Appearance. |
| 118 |
'adminBarMode' => 'hidden', |
| 119 |
// Always on screen. `dynamic` (auto-hide behind a peek strip) |
| 120 |
// is one pick away in OpenStation Preferences → Appearance. |
| 121 |
'dockBehavior' => 'static', |
| 122 |
// The Split layout's sidebar answers for itself: a folded |
| 123 |
// sidebar over a static bottom dock is a valid desk. |
| 124 |
'sideDockBehavior' => 'static', |
| 125 |
// One dock holding every menu, with the system tiles grouped |
| 126 |
// behind a hairline. `classic` (side bar for core menus + bottom |
| 127 |
// dock for plugins) is the other option; it is no longer what a |
| 128 |
// first-run desktop looks like. |
| 129 |
'desktopLayout' => 'unified', |
| 130 |
// Which edge the single dock sits on. Ignored by `classic`, |
| 131 |
// which derives both of its rails from the layout. |
| 132 |
'dockPlacement' => 'bottom', |
| 133 |
'dockRailRenderer' => 'default', |
| 134 |
// Active desktop-theme slug, or `''` for the system default. |
| 135 |
// Site-wide library (`includes/desktop-themes/`), per-user |
| 136 |
// activation. Not validated against the installed list here — |
| 137 |
// the enqueue path checks existence on every request, so a |
| 138 |
// deleted theme degrades silently instead of needing a |
| 139 |
// user-meta rewrite. |
| 140 |
'desktopTheme' => '', |
| 141 |
// Slugs of the desktop themes whose `recommendedOsSettings` |
| 142 |
// block has already been applied for this user. A theme's |
| 143 |
// recommendations are seeded ONCE — the first time the user |
| 144 |
// activates it — and this list is the record of that. It is |
| 145 |
// what makes "never overwrite a user's later choices" true: |
| 146 |
// re-activating a theme they have worn before changes |
| 147 |
// nothing. The Themes tab's "Apply recommended layout" action |
| 148 |
// is the deliberate way back. Capped at 64 slugs. |
| 149 |
'appliedThemeRecommendations' => array(), |
| 150 |
'unfocusEffect' => 'darken', |
| 151 |
// Window-reveal id — the clip-path transition that uncovers a |
| 152 |
// window's content once it finishes loading. Off by default; |
| 153 |
// `none` is the plain opacity fade the shell has always had. |
| 154 |
'windowReveal' => 'none', |
| 155 |
// Global reveal duration override in ms. 0 means "use each |
| 156 |
// reveal's own tuned timing" — the shipped reveals have |
| 157 |
// durations chosen per shape (Radar's full turn is slower |
| 158 |
// than Sweep's straight line), and one flat number would |
| 159 |
// lose that. |
| 160 |
'windowRevealDuration' => 0, |
| 161 |
// Window-link renderer id — how relation ties between windows |
| 162 |
// are drawn (see includes/window-links.php). `svg-splines` is |
| 163 |
// the shipped built-in; `none` disables the visuals. |
| 164 |
'windowLinkRenderer' => 'svg-splines', |
| 165 |
// When the ties are visible: 'always' (default), 'focus' (only |
| 166 |
// while a group member is focused), or 'off'. |
| 167 |
'windowLinkVisibility' => 'always', |
| 168 |
// Master switch for the window-links feature (OS Settings → |
| 169 |
// Features). Off unmounts the visuals AND the group behaviors |
| 170 |
// below; the style knobs above keep their values for when it |
| 171 |
// comes back on. |
| 172 |
'windowLinksEnabled' => true, |
| 173 |
// Focusing a relation-group member raises its related windows |
| 174 |
// to just below it (silent restack, no focus theft). |
| 175 |
'windowLinkRaiseOnFocus' => true, |
| 176 |
// Related windows of the focused member get a subtle outline. |
| 177 |
'windowLinkHighlight' => true, |
| 178 |
'customGradient' => array( |
| 179 |
'from' => '#2271b1', |
| 180 |
'to' => '#7c3aed', |
| 181 |
'angle' => 135, |
| 182 |
), |
| 183 |
'customImage' => null, |
| 184 |
// Per-wallpaper settings bags, keyed by wallpaper id — the |
| 185 |
// values a wallpaper's `renderConfig` dialog writes (e.g. the |
| 186 |
// Snow wallpaper's wind / particle count / flake size / |
| 187 |
// background). Scalar values only; the wallpaper owns the keys' |
| 188 |
// meaning. Missing ids mean "never configured" — the wallpaper |
| 189 |
// falls back to its defaults. Capped at 64 wallpapers × 32 keys. |
| 190 |
'wallpaperSettings' => array(), |
| 191 |
'libraryHdOnly' => true, |
| 192 |
'ai' => array( |
| 193 |
'enabled' => false, // AI assistant is opt-in; enabled from OS Settings → Features once a provider is configured. |
| 194 |
), |
| 195 |
// Per-user opt-IN for the native Posts window. When true, |
| 196 |
// clicking the Posts dock tile opens the `<os-table>`-driven |
| 197 |
// native window instead of the chromeless `edit.php` iframe. |
| 198 |
// Default OFF — the native windows are opt-in |
| 199 |
// Beta. Fresh installs land on the classic iframe; users turn |
| 200 |
// this on in OS Settings → Features → Beta features to try it. |
| 201 |
// Per-user override of the WordPress Heartbeat interval, in |
| 202 |
// seconds. 60s matches Core's "idle" default; the allowed |
| 203 |
// rates (15/30/45/60) all sit at or above Core's 15 s |
| 204 |
// `minimalInterval` floor. See |
| 205 |
// `openstation_apply_heartbeat_rate_setting` for the |
| 206 |
// `heartbeat_settings` filter that applies this. |
| 207 |
'heartbeatRate' => 60, |
| 208 |
'nativePostsEnabled' => false, |
| 209 |
// Per-user list of column keys hidden in the native Posts |
| 210 |
// window (e.g. array( 'author', 'tags' )). Empty array means |
| 211 |
// every column is visible. The sticky 'title' column is always |
| 212 |
// shown — the UI prevents toggling it. |
| 213 |
'nativePostsHiddenColumns' => array(), |
| 214 |
// Per-user opt-IN for the native Pages window. Same posture as |
| 215 |
// nativePostsEnabled — defaults OFF (Beta), users opt in to swap |
| 216 |
// the classic `edit.php?post_type=page` iframe for the native UI. |
| 217 |
'nativePagesEnabled' => false, |
| 218 |
// Per-user opt-IN for the native Users window. Defaults OFF |
| 219 |
// (Beta); the server-side cap gate (`list_users`) means the |
| 220 |
// toggle only matters for users who could see the Users tile. |
| 221 |
'nativeUsersEnabled' => false, |
| 222 |
// Per-user opt-IN for the native Plugins window. Defaults OFF |
| 223 |
// (Beta); the server-side cap gate (`activate_plugins`) means |
| 224 |
// the toggle only matters for users who could see the Plugins |
| 225 |
// tile anyway. When `false`, the dock click uses the classic |
| 226 |
// `plugins.php` chromeless iframe path. |
| 227 |
'nativePluginsEnabled' => false, |
| 228 |
// Per-user opt-IN for the native Comments window. Defaults OFF |
| 229 |
// (Beta); the server-side cap gate (`edit_posts`) means the |
| 230 |
// toggle only matters for users who could see the Comments tile. |
| 231 |
'nativeCommentsEnabled' => false, |
| 232 |
// Per-user opt-IN for Station Home, the native Dashboard |
| 233 |
// window. Defaults OFF: the ordinary `index.php` Dashboard |
| 234 |
// (including any custom dashboard a plugin builds there) opens |
| 235 |
// as a chromeless iframe until the user opts in via OS |
| 236 |
// Settings → Features → Beta features. |
| 237 |
'stationHomeEnabled' => false, |
| 238 |
// Performance enhancements are enabled unless explicitly disabled. |
| 239 |
'adminAssetCacheEnabled' => true, |
| 240 |
'windowPrewarmEnabled' => true, |
| 241 |
// When true, left-clicking the empty wallpaper triggers the |
| 242 |
// "Show desktop" toggle (macOS-style) and the matching entry is |
| 243 |
// hidden from the wallpaper context menu. When false (default), |
| 244 |
// the entry stays in the menu and left clicks on the wallpaper |
| 245 |
// do nothing. Per-user. |
| 246 |
'showDesktopOnWallpaperClick' => false, |
| 247 |
// Whether the close-all-windows shortcut (Alt+Cmd/Ctrl+W) asks |
| 248 |
// before it closes. The dialog's "Don't ask again" checkbox is |
| 249 |
// what writes false; OpenStation Preferences -> Windows is what |
| 250 |
// turns it back on. Per-user. |
| 251 |
'confirmCloseAllWindows' => true, |
| 252 |
// Mio — a soft-body companion that floats over |
| 253 |
// the wallpaper, settles onto nearby windows, and watches the |
| 254 |
// pointer. Off by default; toggled from the wallpaper context |
| 255 |
// menu. Per-user. See `docs/mio.md`. |
| 256 |
'mioEnabled' => false, |
| 257 |
'mioApiEnabled' => false, |
| 258 |
'mioShowOnWallpaper' => true, |
| 259 |
// The user's own Mio, as built in "Make it yours": partial |
| 260 |
// appearance + silhouette overrides, both empty until they |
| 261 |
// touch a control. Stored per user rather than per browser |
| 262 |
// because it is a preference about the person — ten minutes |
| 263 |
// spent building a companion should be waiting on their phone. |
| 264 |
// Sanitized by `openstation_sanitize_mio_look()`; the ranges |
| 265 |
// are enforced client-side in `sanitizeMioConfig()`. |
| 266 |
'mioStyle' => array( |
| 267 |
'appearance' => array(), |
| 268 |
'physics' => array(), |
| 269 |
), |
| 270 |
// Diagonal corner ribbon on My WordPress tiles whose post |
| 271 |
// status isn't `publish` (draft / pending / private / |
| 272 |
// scheduled). On by default — surfaces unpublished work at |
| 273 |
// a glance. Per-user. |
| 274 |
'showPostStatusRibbons' => true, |
| 275 |
// Unlocks developer-facing surfaces meant for plugin |
| 276 |
// authors: the Starter Widget appears in the add-widget |
| 277 |
// picker, the OS Settings → Components tab runs its |
| 278 |
// intentional missing-import-warner demo, and the Code Blue |
| 279 |
// error-log reader registers (icon, window, REST routes). |
| 280 |
// Off by default. Per-user. |
| 281 |
'developerModeEnabled' => false, |
| 282 |
// Per-user opt-OUT for the folder-sharing feature. Defaults |
| 283 |
// ON. When false: |
| 284 |
// - The Share button, share-settings modal, "Leave shared |
| 285 |
// folder" entry, and pending-invite prompt are all |
| 286 |
// suppressed in the user's shell. |
| 287 |
// - The heartbeat skips the `shares.pending` payload for |
| 288 |
// this user so they never see invites land. |
| 289 |
// - REST share routes return 404 for this user — they |
| 290 |
// can't list, invite, accept, deny, or leave. |
| 291 |
// Sites that don't want the feature (solo admin, no |
| 292 |
// collaborators) can flip the toggle and the surface |
| 293 |
// disappears without any database changes. The site-wide |
| 294 |
// "Delete folder sharing data" action in OS Settings → |
| 295 |
// Features → Advanced is a separate destructive cleanup. |
| 296 |
'foldersSharingEnabled' => true, |
| 297 |
// Per-item navigation placement. Map of item id → one of: |
| 298 |
// 'both' — show on a rail and on the desktop. |
| 299 |
// 'rail' — show only on a rail: the dock, or the sidebar |
| 300 |
// for a Core admin menu in the split layout. |
| 301 |
// 'desktop' — show only on the wallpaper. |
| 302 |
// 'hidden' — hide from every shell surface. |
| 303 |
// Missing keys mean "no override" — the item takes the default |
| 304 |
// for its kind, which lives in `src/nav/defaults.ts`. |
| 305 |
// Sanitized as map<sanitize_key, enum>. Capped at 256 entries. |
| 306 |
'navPlacement' => array(), |
| 307 |
// Per-user ordering, flat across every dock/sidebar zone. Ids |
| 308 |
// not in the list keep their registration order and render |
| 309 |
// after the listed ones. Unknown ids are tolerated. |
| 310 |
'navOrder' => array(), |
| 311 |
// Which experience the shell renders: 'auto' follows the |
| 312 |
// viewport, 'desktop' / 'mobile' force it. See `includes/mobile.php`. |
| 313 |
'mobileLayout' => 'auto', |
| 314 |
// Ids pinned to the phone tab bar, at most three. Empty means |
| 315 |
// the server default (`openstation_mobile_tab_bar`). |
| 316 |
'mobileTabs' => array(), |
| 317 |
// Persisted desktop position for every item the user has |
| 318 |
// promoted to the wallpaper via `navPlacement[id]=desktop|both`. |
| 319 |
// Keyed by item id, value is `{ x: int, y: int }`. The JS |
| 320 |
// synthesizer reads this when building a synthetic placement so |
| 321 |
// the icon lands where the user last dragged it instead of |
| 322 |
// resetting to (0, 0) on every reload. Capped at 256 entries. |
| 323 |
'dockPromotedPositions' => array(), |
| 324 |
); |
| 325 |
} |
| 326 |
|
| 327 |
/** |
| 328 |
* Retrieves the saved OS settings for a user. |
| 329 |
* |
| 330 |
* Always returns a fully-shaped array so the JS side doesn't need to |
| 331 |
* defend against partial or missing keys. |
| 332 |
* |
| 333 |
* @param int $user_id The user ID. |
| 334 |
* @return array |
| 335 |
*/ |
| 336 |
function openstation_get_os_settings( $user_id ) { |
| 337 |
$user_id = (int) $user_id; |
| 338 |
if ( $user_id <= 0 ) { |
| 339 |
return openstation_sanitize_os_settings( array() ); |
| 340 |
} |
| 341 |
|
| 342 |
$raw = get_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true ); |
| 343 |
if ( ! is_array( $raw ) ) { |
| 344 |
return openstation_sanitize_os_settings( array() ); |
| 345 |
} |
| 346 |
|
| 347 |
return openstation_sanitize_os_settings( $raw ); |
| 348 |
} |
| 349 |
|
| 350 |
/** |
| 351 |
* Saves sanitized OS settings for a user. |
| 352 |
* |
| 353 |
* @param int $user_id The user ID. |
| 354 |
* @param mixed $settings Raw settings payload from the client. |
| 355 |
* @return bool True on success, false otherwise. |
| 356 |
*/ |
| 357 |
function openstation_save_os_settings( $user_id, $settings ) { |
| 358 |
$user_id = (int) $user_id; |
| 359 |
if ( $user_id <= 0 ) { |
| 360 |
return false; |
| 361 |
} |
| 362 |
|
| 363 |
$clean = openstation_sanitize_os_settings( $settings ); |
| 364 |
return false !== update_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, $clean ); |
| 365 |
} |
| 366 |
|
| 367 |
/** |
| 368 |
* Strip the rail-synthesis prefix an id could carry before the |
| 369 |
* navigation model. |
| 370 |
* |
| 371 |
* `dock:<id>` / `desktop:<id>` used to mean "this tile is a copy of an |
| 372 |
* item whose real home is the other rail". Nothing synthesizes copies |
| 373 |
* any more — an item is one item wherever it is painted — so the |
| 374 |
* prefix is noise, and left in place it would key a preference to an |
| 375 |
* id nothing registers. |
| 376 |
* |
| 377 |
* @param string $id Possibly-prefixed id. |
| 378 |
* @return string Canonical id. |
| 379 |
*/ |
| 380 |
function openstation_canonical_nav_id( $id ) { |
| 381 |
$id = (string) $id; |
| 382 |
if ( 0 === strpos( $id, 'dock:' ) ) { |
| 383 |
return substr( $id, 5 ); |
| 384 |
} |
| 385 |
if ( 0 === strpos( $id, 'desktop:' ) ) { |
| 386 |
return substr( $id, 8 ); |
| 387 |
} |
| 388 |
return $id; |
| 389 |
} |
| 390 |
|
| 391 |
/** |
| 392 |
* Carry a pre-navigation `itemVisibility` map into `navPlacement`. |
| 393 |
* |
| 394 |
* The only value that moves is `'dock'` → `'rail'`: the stored name |
| 395 |
* is now the REGION rather than a rail, so a Core admin menu the user |
| 396 |
* kept on a rail follows the layout into the sidebar instead of |
| 397 |
* needing a second migration the first time they switch. |
| 398 |
* |
| 399 |
* Runs on read (see {@see openstation_sanitize_os_settings()}) rather |
| 400 |
* than as a numbered migration, because OS settings are per-user meta |
| 401 |
* and a site with many users would pay for a sweep that the next save |
| 402 |
* performs for free. |
| 403 |
* |
| 404 |
* @param array $visibility Legacy map of item id → placement. |
| 405 |
* @return array Map of canonical item id → nav placement. |
| 406 |
*/ |
| 407 |
function openstation_migrate_item_visibility( $visibility ) { |
| 408 |
$map = array( |
| 409 |
'dock' => 'rail', |
| 410 |
'desktop' => 'desktop', |
| 411 |
'both' => 'both', |
| 412 |
'hidden' => 'hidden', |
| 413 |
); |
| 414 |
|
| 415 |
$out = array(); |
| 416 |
foreach ( (array) $visibility as $key => $val ) { |
| 417 |
if ( ! is_string( $key ) || ! is_string( $val ) || ! isset( $map[ $val ] ) ) { |
| 418 |
continue; |
| 419 |
} |
| 420 |
$id = openstation_canonical_nav_id( $key ); |
| 421 |
if ( '' === $id ) { |
| 422 |
continue; |
| 423 |
} |
| 424 |
// A prefixed and an unprefixed key can collapse onto the same |
| 425 |
// id. The unprefixed one is the item's own preference rather |
| 426 |
// than a synthesized copy's, so it wins whichever order they |
| 427 |
// arrive in. |
| 428 |
if ( $id === $key || ! isset( $out[ $id ] ) ) { |
| 429 |
$out[ $id ] = $map[ $val ]; |
| 430 |
} |
| 431 |
} |
| 432 |
return $out; |
| 433 |
} |
| 434 |
|
| 435 |
/** |
| 436 |
* Sanitizes a raw OS settings payload. |
| 437 |
* |
| 438 |
* Unknown keys are ignored; known keys are coerced field-by-field so a |
| 439 |
* partial save (e.g., only accent changed) merges cleanly with the |
| 440 |
* defaults rather than wiping unset fields. |
| 441 |
* |
| 442 |
* @param mixed $raw Raw settings from the client or user meta. |
| 443 |
* @return array Sanitized settings. |
| 444 |
*/ |
| 445 |
function openstation_sanitize_os_settings( $raw ) { |
| 446 |
$defaults = openstation_default_os_settings(); |
| 447 |
|
| 448 |
if ( ! is_array( $raw ) ) { |
| 449 |
$raw = array(); |
| 450 |
} |
| 451 |
|
| 452 |
// Wallpaper — any non-empty string; registry membership is validated |
| 453 |
// client-side at apply time. |
| 454 |
$wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] ) && '' !== $raw['wallpaper'] |
| 455 |
? sanitize_key( $raw['wallpaper'] ) |
| 456 |
: $defaults['wallpaper']; |
| 457 |
|
| 458 |
// Accent — non-empty string; swatch validity is enforced in the picker. |
| 459 |
$accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent'] |
| 460 |
? sanitize_key( $raw['accent'] ) |
| 461 |
: $defaults['accent']; |
| 462 |
|
| 463 |
// The colour behind the Custom swatch. A full `#rrggbb` triplet and |
| 464 |
// nothing else: `sanitize_hex_color()` would also pass `#abc`, which |
| 465 |
// the client-side parser rejects, and a value that survives the save |
| 466 |
// only to be dropped on load is worse than one refused here. |
| 467 |
$custom_accent = isset( $raw['customAccent'] ) |
| 468 |
&& is_string( $raw['customAccent'] ) |
| 469 |
&& preg_match( '/^#[0-9a-fA-F]{6}$/', $raw['customAccent'] ) |
| 470 |
? strtolower( $raw['customAccent'] ) |
| 471 |
: $defaults['customAccent']; |
| 472 |
|
| 473 |
// Dock size — must be one of the three known values. |
| 474 |
$dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], OPENSTATION_OS_SETTINGS_DOCK_SIZES, true ) |
| 475 |
? (string) $raw['dockSize'] |
| 476 |
: $defaults['dockSize']; |
| 477 |
|
| 478 |
// Window radius — must be one of the three known values. |
| 479 |
$window_radius = isset( $raw['windowRadius'] ) && in_array( $raw['windowRadius'], OPENSTATION_OS_SETTINGS_WINDOW_RADII, true ) |
| 480 |
? (string) $raw['windowRadius'] |
| 481 |
: $defaults['windowRadius']; |
| 482 |
|
| 483 |
// Admin-bar mode — must be one of the three known values. |
| 484 |
$admin_bar_mode = isset( $raw['adminBarMode'] ) |
| 485 |
&& in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true ) |
| 486 |
? (string) $raw['adminBarMode'] |
| 487 |
: $defaults['adminBarMode']; |
| 488 |
|
| 489 |
// Dock behavior — must be one of the two known values. One answer |
| 490 |
// per rail: the dock, and the Split layout's sidebar. |
| 491 |
$dock_behavior = isset( $raw['dockBehavior'] ) |
| 492 |
&& in_array( $raw['dockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true ) |
| 493 |
? (string) $raw['dockBehavior'] |
| 494 |
: $defaults['dockBehavior']; |
| 495 |
$side_dock_behavior = isset( $raw['sideDockBehavior'] ) |
| 496 |
&& in_array( $raw['sideDockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true ) |
| 497 |
? (string) $raw['sideDockBehavior'] |
| 498 |
: $defaults['sideDockBehavior']; |
| 499 |
|
| 500 |
// Desktop layout — must be one of the known values (`classic`, |
| 501 |
// `unified`). Default `unified`. |
| 502 |
$desktop_layout = isset( $raw['desktopLayout'] ) |
| 503 |
&& in_array( $raw['desktopLayout'], OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS, true ) |
| 504 |
? (string) $raw['desktopLayout'] |
| 505 |
: $defaults['desktopLayout']; |
| 506 |
|
| 507 |
// Dock placement — which edge the single dock sits on. Must be one |
| 508 |
// of the three known values (`bottom`, `left`, `right`). |
| 509 |
$dock_placement = isset( $raw['dockPlacement'] ) |
| 510 |
&& in_array( $raw['dockPlacement'], OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS, true ) |
| 511 |
? (string) $raw['dockPlacement'] |
| 512 |
: $defaults['dockPlacement']; |
| 513 |
|
| 514 |
// Dock rail renderer id — accept any sanitize_key()-clean |
| 515 |
// string. JS-side registry resolves at use time and falls back |
| 516 |
// to `'default'` when the picked renderer isn't registered. |
| 517 |
$dock_rail_renderer = $defaults['dockRailRenderer']; |
| 518 |
if ( isset( $raw['dockRailRenderer'] ) && is_string( $raw['dockRailRenderer'] ) ) { |
| 519 |
$slug = sanitize_key( $raw['dockRailRenderer'] ); |
| 520 |
if ( '' !== $slug ) { |
| 521 |
$dock_rail_renderer = $slug; |
| 522 |
} |
| 523 |
} |
| 524 |
|
| 525 |
// Desktop theme slug — a pattern check, NOT an allow-list, the |
| 526 |
// same idiom as `dockRailRenderer` above. Validating against the |
| 527 |
// installed-theme option here would load (and unserialize) that |
| 528 |
// option on every single settings write for a value the enqueue |
| 529 |
// path re-checks anyway. `''` is the system default and is a |
| 530 |
// legitimate value, so an empty/absent key keeps the default. |
| 531 |
$desktop_theme = $defaults['desktopTheme']; |
| 532 |
if ( isset( $raw['desktopTheme'] ) && is_string( $raw['desktopTheme'] ) ) { |
| 533 |
$desktop_theme = sanitize_key( $raw['desktopTheme'] ); |
| 534 |
} |
| 535 |
|
| 536 |
// appliedThemeRecommendations — list of desktop-theme slugs whose |
| 537 |
// recommendations this user has already been seeded with. Unknown |
| 538 |
// slugs are kept (a deleted-then-reinstalled theme must not |
| 539 |
// re-seed and clobber the settings the user has since chosen). |
| 540 |
$applied_theme_recommendations = $defaults['appliedThemeRecommendations']; |
| 541 |
if ( isset( $raw['appliedThemeRecommendations'] ) && is_array( $raw['appliedThemeRecommendations'] ) ) { |
| 542 |
$applied_theme_recommendations = array(); |
| 543 |
foreach ( $raw['appliedThemeRecommendations'] as $theme_slug ) { |
| 544 |
if ( ! is_string( $theme_slug ) || '' === $theme_slug ) { |
| 545 |
continue; |
| 546 |
} |
| 547 |
$theme_slug = sanitize_key( $theme_slug ); |
| 548 |
if ( '' === $theme_slug ) { |
| 549 |
continue; |
| 550 |
} |
| 551 |
$applied_theme_recommendations[] = $theme_slug; |
| 552 |
} |
| 553 |
// Keep the MOST RECENT 64, not the first 64 — the client |
| 554 |
// appends, so trimming from the front would silently discard |
| 555 |
// the entry that was just written and let the theme re-seed on |
| 556 |
// the next activation. |
| 557 |
$applied_theme_recommendations = array_slice( |
| 558 |
array_values( array_unique( $applied_theme_recommendations ) ), |
| 559 |
-64 |
| 560 |
); |
| 561 |
} |
| 562 |
|
| 563 |
// Unfocus effect id — accept the `none` sentinel or any registry id. |
| 564 |
// Effect ids mirror the JS registry pattern `^[a-z0-9_/-]+$` (slashes |
| 565 |
// allowed for `vendor/sub-id` namespacing), so we lower-case and strip |
| 566 |
// to that charset rather than using sanitize_key() (which would drop |
| 567 |
// the slash and break a namespaced id on round-trip). No allow-list: |
| 568 |
// the JS engine resolves at use time and treats an unknown id as "no |
| 569 |
// effect". |
| 570 |
$unfocus_effect = $defaults['unfocusEffect']; |
| 571 |
if ( isset( $raw['unfocusEffect'] ) && is_string( $raw['unfocusEffect'] ) ) { |
| 572 |
$slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['unfocusEffect'] ) ); |
| 573 |
if ( '' !== $slug ) { |
| 574 |
$unfocus_effect = $slug; |
| 575 |
} |
| 576 |
} |
| 577 |
|
| 578 |
// Window-reveal id — same id charset and same no-allow-list |
| 579 |
// reasoning as the unfocus effect above. The JS surface resolves at |
| 580 |
// play time and treats an unknown id as "no reveal", so a reveal |
| 581 |
// belonging to a temporarily-deactivated plugin survives the |
| 582 |
// round-trip and starts working again the moment it re-registers. |
| 583 |
$window_reveal = $defaults['windowReveal']; |
| 584 |
if ( isset( $raw['windowReveal'] ) && is_string( $raw['windowReveal'] ) ) { |
| 585 |
$slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowReveal'] ) ); |
| 586 |
if ( '' !== $slug ) { |
| 587 |
$window_reveal = $slug; |
| 588 |
} |
| 589 |
} |
| 590 |
|
| 591 |
// Window-reveal duration override — 0 (the default) means "leave |
| 592 |
// each reveal's own timing alone". Anything else is clamped into |
| 593 |
// the playable range rather than rejected: a value past the end of |
| 594 |
// the range still expresses a direction, and the nearest playable |
| 595 |
// duration is the honest reading of it. |
| 596 |
$window_reveal_duration = $defaults['windowRevealDuration']; |
| 597 |
if ( isset( $raw['windowRevealDuration'] ) && is_numeric( $raw['windowRevealDuration'] ) ) { |
| 598 |
$requested = (int) round( (float) $raw['windowRevealDuration'] ); |
| 599 |
if ( $requested > 0 ) { |
| 600 |
$window_reveal_duration = max( |
| 601 |
OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN, |
| 602 |
min( OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX, $requested ) |
| 603 |
); |
| 604 |
} else { |
| 605 |
$window_reveal_duration = 0; |
| 606 |
} |
| 607 |
} |
| 608 |
|
| 609 |
// Window-link renderer id — same id charset as unfocus effects |
| 610 |
// (slashes allowed for `vendor/sub-id`). No allow-list: the JS |
| 611 |
// render host resolves at use time and falls back to the built-in |
| 612 |
// `svg-splines` when the picked renderer isn't registered. |
| 613 |
$window_link_renderer = $defaults['windowLinkRenderer']; |
| 614 |
if ( isset( $raw['windowLinkRenderer'] ) && is_string( $raw['windowLinkRenderer'] ) ) { |
| 615 |
$slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowLinkRenderer'] ) ); |
| 616 |
if ( '' !== $slug ) { |
| 617 |
$window_link_renderer = $slug; |
| 618 |
} |
| 619 |
} |
| 620 |
|
| 621 |
// Window-link visibility — small closed set. |
| 622 |
$window_link_visibility = $defaults['windowLinkVisibility']; |
| 623 |
if ( |
| 624 |
isset( $raw['windowLinkVisibility'] ) |
| 625 |
&& in_array( $raw['windowLinkVisibility'], array( 'focus', 'always', 'off' ), true ) |
| 626 |
) { |
| 627 |
$window_link_visibility = $raw['windowLinkVisibility']; |
| 628 |
} |
| 629 |
|
| 630 |
// Window-links feature switches — plain booleans. |
| 631 |
$window_links_enabled = isset( $raw['windowLinksEnabled'] ) |
| 632 |
? (bool) $raw['windowLinksEnabled'] |
| 633 |
: $defaults['windowLinksEnabled']; |
| 634 |
|
| 635 |
$window_link_raise_on_focus = isset( $raw['windowLinkRaiseOnFocus'] ) |
| 636 |
? (bool) $raw['windowLinkRaiseOnFocus'] |
| 637 |
: $defaults['windowLinkRaiseOnFocus']; |
| 638 |
|
| 639 |
$window_link_highlight = isset( $raw['windowLinkHighlight'] ) |
| 640 |
? (bool) $raw['windowLinkHighlight'] |
| 641 |
: $defaults['windowLinkHighlight']; |
| 642 |
|
| 643 |
// Custom gradient — { from, to: valid hex; angle: int 0–360 }. |
| 644 |
$custom_gradient = $defaults['customGradient']; |
| 645 |
if ( isset( $raw['customGradient'] ) && is_array( $raw['customGradient'] ) ) { |
| 646 |
$cg = $raw['customGradient']; |
| 647 |
if ( isset( $cg['from'] ) && is_string( $cg['from'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['from'] ) ) { |
| 648 |
$custom_gradient['from'] = strtolower( $cg['from'] ); |
| 649 |
} |
| 650 |
if ( isset( $cg['to'] ) && is_string( $cg['to'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['to'] ) ) { |
| 651 |
$custom_gradient['to'] = strtolower( $cg['to'] ); |
| 652 |
} |
| 653 |
if ( isset( $cg['angle'] ) && is_numeric( $cg['angle'] ) ) { |
| 654 |
$angle = (int) $cg['angle']; |
| 655 |
if ( $angle >= 0 && $angle <= 360 ) { |
| 656 |
$custom_gradient['angle'] = $angle; |
| 657 |
} |
| 658 |
} |
| 659 |
} |
| 660 |
|
| 661 |
// Custom image — { id: positive int, url: valid https? URL } or null. |
| 662 |
$custom_image = null; |
| 663 |
if ( isset( $raw['customImage'] ) && is_array( $raw['customImage'] ) ) { |
| 664 |
$ci = $raw['customImage']; |
| 665 |
$ci_id = isset( $ci['id'] ) && is_numeric( $ci['id'] ) ? (int) $ci['id'] : 0; |
| 666 |
$ci_url = isset( $ci['url'] ) ? esc_url_raw( (string) $ci['url'] ) : ''; |
| 667 |
if ( $ci_id > 0 && '' !== $ci_url && preg_match( '/^https?:\/\//i', $ci_url ) ) { |
| 668 |
$custom_image = array( |
| 669 |
'id' => $ci_id, |
| 670 |
'url' => $ci_url, |
| 671 |
); |
| 672 |
} |
| 673 |
} |
| 674 |
|
| 675 |
// wallpaperSettings — map<wallpaper id, map<key, scalar>>. Wallpaper |
| 676 |
// ids follow the same charset as unfocus-effect ids (slashes allowed |
| 677 |
// for `vendor/sub-id` namespacing); setting keys follow the JS |
| 678 |
// identifier-ish charset wallpaper authors use (camelCase, hyphens, |
| 679 |
// underscores). Values must be scalar — booleans and numbers pass |
| 680 |
// through typed, strings are sanitized and length-capped. Unknown |
| 681 |
// wallpaper ids are kept (a deactivated wallpaper plugin's settings |
| 682 |
// should survive reactivation). Capped at 64 ids × 32 keys. |
| 683 |
$wallpaper_settings = array(); |
| 684 |
if ( isset( $raw['wallpaperSettings'] ) && is_array( $raw['wallpaperSettings'] ) ) { |
| 685 |
$id_count = 0; |
| 686 |
foreach ( $raw['wallpaperSettings'] as $wp_id => $bag ) { |
| 687 |
if ( $id_count >= 64 ) { |
| 688 |
break; |
| 689 |
} |
| 690 |
if ( ! is_string( $wp_id ) || '' === $wp_id || ! is_array( $bag ) ) { |
| 691 |
continue; |
| 692 |
} |
| 693 |
$wp_slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $wp_id ) ); |
| 694 |
if ( '' === $wp_slug ) { |
| 695 |
continue; |
| 696 |
} |
| 697 |
$clean_bag = array(); |
| 698 |
$key_count = 0; |
| 699 |
foreach ( $bag as $key => $value ) { |
| 700 |
if ( $key_count >= 32 ) { |
| 701 |
break; |
| 702 |
} |
| 703 |
if ( ! is_string( $key ) || '' === $key || ! preg_match( '/^[a-zA-Z0-9_-]+$/', $key ) ) { |
| 704 |
continue; |
| 705 |
} |
| 706 |
if ( is_bool( $value ) ) { |
| 707 |
$clean_bag[ $key ] = $value; |
| 708 |
} elseif ( is_int( $value ) || is_float( $value ) ) { |
| 709 |
if ( ! is_finite( (float) $value ) ) { |
| 710 |
continue; |
| 711 |
} |
| 712 |
$clean_bag[ $key ] = $value; |
| 713 |
} elseif ( is_string( $value ) ) { |
| 714 |
$clean_bag[ $key ] = mb_substr( sanitize_text_field( $value ), 0, 256 ); |
| 715 |
} else { |
| 716 |
continue; |
| 717 |
} |
| 718 |
++$key_count; |
| 719 |
} |
| 720 |
if ( empty( $clean_bag ) ) { |
| 721 |
continue; |
| 722 |
} |
| 723 |
$wallpaper_settings[ $wp_slug ] = $clean_bag; |
| 724 |
++$id_count; |
| 725 |
} |
| 726 |
} |
| 727 |
|
| 728 |
// Library HD only — boolean. |
| 729 |
$library_hd_only = isset( $raw['libraryHdOnly'] ) ? (bool) $raw['libraryHdOnly'] : $defaults['libraryHdOnly']; |
| 730 |
|
| 731 |
// AI settings — just the per-user on/off toggle. Provider + model selection |
| 732 |
// is delegated to the Core AI Client, so there is no preference to persist. |
| 733 |
$ai = $defaults['ai']; |
| 734 |
if ( isset( $raw['ai'] ) && is_array( $raw['ai'] ) ) { |
| 735 |
$raw_ai = $raw['ai']; |
| 736 |
|
| 737 |
if ( isset( $raw_ai['enabled'] ) ) { |
| 738 |
$ai['enabled'] = (bool) $raw_ai['enabled']; |
| 739 |
} |
| 740 |
} |
| 741 |
|
| 742 |
// Heartbeat rate — one of the four allowed values. The PHP |
| 743 |
// filter `openstation_apply_heartbeat_rate_setting` reads |
| 744 |
// this and passes it through to `heartbeat_settings` so |
| 745 |
// WordPress Core itself reduces the interval on the next page |
| 746 |
// load. 5 s is intentionally excluded: Core's |
| 747 |
// `minimalInterval` floor clamps anything below 15 back up to |
| 748 |
// 15 unless every upstream filter cooperates, and the gain |
| 749 |
// over 15 s is marginal. |
| 750 |
$allowed_heartbeat_rates = array( 15, 30, 45, 60 ); |
| 751 |
$heartbeat_rate = $defaults['heartbeatRate']; |
| 752 |
if ( isset( $raw['heartbeatRate'] ) && is_numeric( $raw['heartbeatRate'] ) ) { |
| 753 |
$candidate = (int) $raw['heartbeatRate']; |
| 754 |
if ( in_array( $candidate, $allowed_heartbeat_rates, true ) ) { |
| 755 |
$heartbeat_rate = $candidate; |
| 756 |
} |
| 757 |
} |
| 758 |
|
| 759 |
$native_posts_enabled = isset( $raw['nativePostsEnabled'] ) |
| 760 |
? (bool) $raw['nativePostsEnabled'] |
| 761 |
: $defaults['nativePostsEnabled']; |
| 762 |
|
| 763 |
$native_posts_hidden_columns = $defaults['nativePostsHiddenColumns']; |
| 764 |
if ( isset( $raw['nativePostsHiddenColumns'] ) && is_array( $raw['nativePostsHiddenColumns'] ) ) { |
| 765 |
$native_posts_hidden_columns = array(); |
| 766 |
foreach ( $raw['nativePostsHiddenColumns'] as $col ) { |
| 767 |
if ( ! is_string( $col ) || '' === $col ) { |
| 768 |
continue; |
| 769 |
} |
| 770 |
$slug = sanitize_key( $col ); |
| 771 |
if ( '' === $slug ) { |
| 772 |
continue; |
| 773 |
} |
| 774 |
$native_posts_hidden_columns[] = $slug; |
| 775 |
} |
| 776 |
// Cap to a sane upper bound — far more than any plausible |
| 777 |
// column count, but blocks a malicious payload from bloating |
| 778 |
// user meta indefinitely. |
| 779 |
$native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 ); |
| 780 |
} |
| 781 |
|
| 782 |
$native_pages_enabled = isset( $raw['nativePagesEnabled'] ) |
| 783 |
? (bool) $raw['nativePagesEnabled'] |
| 784 |
: $defaults['nativePagesEnabled']; |
| 785 |
|
| 786 |
$native_users_enabled = isset( $raw['nativeUsersEnabled'] ) |
| 787 |
? (bool) $raw['nativeUsersEnabled'] |
| 788 |
: $defaults['nativeUsersEnabled']; |
| 789 |
|
| 790 |
$native_plugins_enabled = isset( $raw['nativePluginsEnabled'] ) |
| 791 |
? (bool) $raw['nativePluginsEnabled'] |
| 792 |
: $defaults['nativePluginsEnabled']; |
| 793 |
|
| 794 |
$native_comments_enabled = isset( $raw['nativeCommentsEnabled'] ) |
| 795 |
? (bool) $raw['nativeCommentsEnabled'] |
| 796 |
: $defaults['nativeCommentsEnabled']; |
| 797 |
|
| 798 |
$station_home_enabled = isset( $raw['stationHomeEnabled'] ) |
| 799 |
? (bool) $raw['stationHomeEnabled'] |
| 800 |
: $defaults['stationHomeEnabled']; |
| 801 |
|
| 802 |
// Site-wide performance controls supersede the legacy per-user values. |
| 803 |
// Keep the snapshot keys so existing window consumers can read them. |
| 804 |
$extended_options = openstation_get_extended_options(); |
| 805 |
$admin_asset_cache_enabled = $extended_options['admin_asset_cache']; |
| 806 |
$window_prewarm_enabled = $extended_options['window_prewarm']; |
| 807 |
|
| 808 |
$show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] ) |
| 809 |
? (bool) $raw['showDesktopOnWallpaperClick'] |
| 810 |
: $defaults['showDesktopOnWallpaperClick']; |
| 811 |
|
| 812 |
$confirm_close_all_windows = isset( $raw['confirmCloseAllWindows'] ) |
| 813 |
? (bool) $raw['confirmCloseAllWindows'] |
| 814 |
: $defaults['confirmCloseAllWindows']; |
| 815 |
|
| 816 |
$mio_enabled = isset( $raw['mioEnabled'] ) |
| 817 |
? (bool) $raw['mioEnabled'] |
| 818 |
: ( isset( $raw['mioApiEnabled'] ) ? (bool) $raw['mioApiEnabled'] : $defaults['mioEnabled'] ); |
| 819 |
|
| 820 |
// A missing key means "no look saved yet", which sanitizes to the |
| 821 |
// same pair of empty arrays the defaults carry — so this needs no |
| 822 |
// isset() branch of its own. |
| 823 |
$mio_style = openstation_sanitize_mio_look( |
| 824 |
isset( $raw['mioStyle'] ) ? $raw['mioStyle'] : null |
| 825 |
); |
| 826 |
|
| 827 |
$show_post_status_ribbons = isset( $raw['showPostStatusRibbons'] ) |
| 828 |
? (bool) $raw['showPostStatusRibbons'] |
| 829 |
: $defaults['showPostStatusRibbons']; |
| 830 |
|
| 831 |
$developer_mode_enabled = isset( $raw['developerModeEnabled'] ) |
| 832 |
? (bool) $raw['developerModeEnabled'] |
| 833 |
: $defaults['developerModeEnabled']; |
| 834 |
|
| 835 |
// mobileLayout — the phone/desktop override. |
| 836 |
$mobile_layout = isset( $raw['mobileLayout'] ) |
| 837 |
&& in_array( $raw['mobileLayout'], OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS, true ) |
| 838 |
? (string) $raw['mobileLayout'] |
| 839 |
: $defaults['mobileLayout']; |
| 840 |
|
| 841 |
// mobileTabs — ordered nav ids pinned to the phone tab bar. Same |
| 842 |
// id grammar as navOrder, capped at the tab bar's slot count. |
| 843 |
$mobile_tabs = array(); |
| 844 |
if ( isset( $raw['mobileTabs'] ) && is_array( $raw['mobileTabs'] ) ) { |
| 845 |
$seen_tabs = array(); |
| 846 |
foreach ( $raw['mobileTabs'] as $id ) { |
| 847 |
if ( ! is_string( $id ) || '' === $id ) { |
| 848 |
continue; |
| 849 |
} |
| 850 |
$slug = sanitize_key( openstation_canonical_nav_id( $id ) ); |
| 851 |
if ( '' === $slug || isset( $seen_tabs[ $slug ] ) ) { |
| 852 |
continue; |
| 853 |
} |
| 854 |
$seen_tabs[ $slug ] = true; |
| 855 |
$mobile_tabs[] = $slug; |
| 856 |
if ( count( $mobile_tabs ) >= OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX ) { |
| 857 |
break; |
| 858 |
} |
| 859 |
} |
| 860 |
} |
| 861 |
|
| 862 |
$folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] ) |
| 863 |
? (bool) $raw['foldersSharingEnabled'] |
| 864 |
: $defaults['foldersSharingEnabled']; |
| 865 |
|
| 866 |
// navPlacement — map<sanitize_key, enum>. Unknown ids are kept |
| 867 |
// (a deactivated plugin's setting should survive reactivation); |
| 868 |
// invalid placement values are dropped. |
| 869 |
// |
| 870 |
// Reads the pre-navigation `itemVisibility` map when this user has |
| 871 |
// no `navPlacement` yet, so an existing arrangement carries over on |
| 872 |
// first load and is written back on the next save. See |
| 873 |
// `openstation_migrate_item_visibility()`. |
| 874 |
$raw_placement = array(); |
| 875 |
if ( isset( $raw['navPlacement'] ) && is_array( $raw['navPlacement'] ) ) { |
| 876 |
$raw_placement = $raw['navPlacement']; |
| 877 |
} elseif ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) { |
| 878 |
$raw_placement = openstation_migrate_item_visibility( $raw['itemVisibility'] ); |
| 879 |
} |
| 880 |
|
| 881 |
$nav_placement = array(); |
| 882 |
if ( ! empty( $raw_placement ) ) { |
| 883 |
$allowed_placements = array( 'both', 'rail', 'desktop', 'hidden' ); |
| 884 |
$count = 0; |
| 885 |
foreach ( $raw_placement as $key => $val ) { |
| 886 |
if ( $count >= 256 ) { |
| 887 |
break; |
| 888 |
} |
| 889 |
if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) { |
| 890 |
continue; |
| 891 |
} |
| 892 |
$slug = sanitize_key( $key ); |
| 893 |
if ( '' === $slug ) { |
| 894 |
continue; |
| 895 |
} |
| 896 |
if ( ! in_array( $val, $allowed_placements, true ) ) { |
| 897 |
continue; |
| 898 |
} |
| 899 |
$nav_placement[ $slug ] = $val; |
| 900 |
++$count; |
| 901 |
} |
| 902 |
} |
| 903 |
|
| 904 |
// navOrder — ordered list of item ids, flat across every zone. |
| 905 |
// Reads the pre-navigation `dockOrder` when absent, stripping the |
| 906 |
// rail-synthesis prefixes (`dock:` / `desktop:`) that model no |
| 907 |
// longer has. |
| 908 |
$raw_order = array(); |
| 909 |
if ( isset( $raw['navOrder'] ) && is_array( $raw['navOrder'] ) ) { |
| 910 |
$raw_order = $raw['navOrder']; |
| 911 |
} elseif ( isset( $raw['dockOrder'] ) && is_array( $raw['dockOrder'] ) ) { |
| 912 |
$raw_order = $raw['dockOrder']; |
| 913 |
} |
| 914 |
|
| 915 |
$nav_order = array(); |
| 916 |
if ( ! empty( $raw_order ) ) { |
| 917 |
$seen = array(); |
| 918 |
foreach ( $raw_order as $id ) { |
| 919 |
if ( ! is_string( $id ) || '' === $id ) { |
| 920 |
continue; |
| 921 |
} |
| 922 |
$slug = sanitize_key( openstation_canonical_nav_id( $id ) ); |
| 923 |
if ( '' === $slug || isset( $seen[ $slug ] ) ) { |
| 924 |
continue; |
| 925 |
} |
| 926 |
$seen[ $slug ] = true; |
| 927 |
$nav_order[] = $slug; |
| 928 |
if ( count( $nav_order ) >= 256 ) { |
| 929 |
break; |
| 930 |
} |
| 931 |
} |
| 932 |
} |
| 933 |
|
| 934 |
// dockPromotedPositions — map<sanitize_key, {x: int, y: int}>. |
| 935 |
// Persisted positions for synthetic dock-promoted placements, so |
| 936 |
// the JS synthesizer can restore the user's manual placement on |
| 937 |
// next reload. Capped at 256; absurd coordinates are dropped. |
| 938 |
$dock_promoted_positions = array(); |
| 939 |
if ( isset( $raw['dockPromotedPositions'] ) && is_array( $raw['dockPromotedPositions'] ) ) { |
| 940 |
$count = 0; |
| 941 |
$max_coord = 100000; // generous; real screens stop in the thousands. |
| 942 |
foreach ( $raw['dockPromotedPositions'] as $key => $val ) { |
| 943 |
if ( $count >= 256 ) { |
| 944 |
break; |
| 945 |
} |
| 946 |
if ( ! is_string( $key ) || '' === $key ) { |
| 947 |
continue; |
| 948 |
} |
| 949 |
$slug = sanitize_key( $key ); |
| 950 |
if ( '' === $slug ) { |
| 951 |
continue; |
| 952 |
} |
| 953 |
if ( ! is_array( $val ) ) { |
| 954 |
continue; |
| 955 |
} |
| 956 |
if ( ! isset( $val['x'] ) || ! isset( $val['y'] ) ) { |
| 957 |
continue; |
| 958 |
} |
| 959 |
$x = is_numeric( $val['x'] ) ? (int) $val['x'] : null; |
| 960 |
$y = is_numeric( $val['y'] ) ? (int) $val['y'] : null; |
| 961 |
if ( null === $x || null === $y ) { |
| 962 |
continue; |
| 963 |
} |
| 964 |
if ( abs( $x ) > $max_coord || abs( $y ) > $max_coord ) { |
| 965 |
continue; |
| 966 |
} |
| 967 |
$dock_promoted_positions[ $slug ] = array( |
| 968 |
'x' => $x, |
| 969 |
'y' => $y, |
| 970 |
); |
| 971 |
++$count; |
| 972 |
} |
| 973 |
} |
| 974 |
|
| 975 |
return array( |
| 976 |
'wallpaper' => $wallpaper, |
| 977 |
'accent' => $accent, |
| 978 |
'customAccent' => $custom_accent, |
| 979 |
'dockSize' => $dock_size, |
| 980 |
'windowRadius' => $window_radius, |
| 981 |
'adminBarMode' => $admin_bar_mode, |
| 982 |
'desktopLayout' => $desktop_layout, |
| 983 |
'dockPlacement' => $dock_placement, |
| 984 |
'dockBehavior' => $dock_behavior, |
| 985 |
'sideDockBehavior' => $side_dock_behavior, |
| 986 |
'dockRailRenderer' => $dock_rail_renderer, |
| 987 |
'desktopTheme' => $desktop_theme, |
| 988 |
'appliedThemeRecommendations' => $applied_theme_recommendations, |
| 989 |
'unfocusEffect' => $unfocus_effect, |
| 990 |
'windowReveal' => $window_reveal, |
| 991 |
'windowRevealDuration' => $window_reveal_duration, |
| 992 |
'windowLinkRenderer' => $window_link_renderer, |
| 993 |
'windowLinkVisibility' => $window_link_visibility, |
| 994 |
'windowLinksEnabled' => $window_links_enabled, |
| 995 |
'windowLinkRaiseOnFocus' => $window_link_raise_on_focus, |
| 996 |
'windowLinkHighlight' => $window_link_highlight, |
| 997 |
'customGradient' => $custom_gradient, |
| 998 |
'customImage' => $custom_image, |
| 999 |
'wallpaperSettings' => $wallpaper_settings, |
| 1000 |
'libraryHdOnly' => $library_hd_only, |
| 1001 |
'ai' => $ai, |
| 1002 |
'heartbeatRate' => $heartbeat_rate, |
| 1003 |
'nativePostsEnabled' => $native_posts_enabled, |
| 1004 |
'nativePostsHiddenColumns' => $native_posts_hidden_columns, |
| 1005 |
'nativePagesEnabled' => $native_pages_enabled, |
| 1006 |
'nativeUsersEnabled' => $native_users_enabled, |
| 1007 |
'nativePluginsEnabled' => $native_plugins_enabled, |
| 1008 |
'nativeCommentsEnabled' => $native_comments_enabled, |
| 1009 |
'stationHomeEnabled' => $station_home_enabled, |
| 1010 |
'adminAssetCacheEnabled' => $admin_asset_cache_enabled, |
| 1011 |
'windowPrewarmEnabled' => $window_prewarm_enabled, |
| 1012 |
'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click, |
| 1013 |
'confirmCloseAllWindows' => $confirm_close_all_windows, |
| 1014 |
'mioEnabled' => $mio_enabled, |
| 1015 |
'mioApiEnabled' => $mio_enabled, |
| 1016 |
'mioShowOnWallpaper' => isset( $raw['mioShowOnWallpaper'] ) ? (bool) $raw['mioShowOnWallpaper'] : $defaults['mioShowOnWallpaper'], |
| 1017 |
'mioStyle' => $mio_style, |
| 1018 |
'showPostStatusRibbons' => $show_post_status_ribbons, |
| 1019 |
'developerModeEnabled' => $developer_mode_enabled, |
| 1020 |
'foldersSharingEnabled' => $folders_sharing_enabled, |
| 1021 |
'navPlacement' => $nav_placement, |
| 1022 |
'navOrder' => $nav_order, |
| 1023 |
'mobileLayout' => $mobile_layout, |
| 1024 |
'mobileTabs' => $mobile_tabs, |
| 1025 |
'dockPromotedPositions' => $dock_promoted_positions, |
| 1026 |
); |
| 1027 |
} |
| 1028 |
|
| 1029 |
/** |
| 1030 |
* Registers the REST routes for OS settings. |
| 1031 |
*/ |
| 1032 |
function openstation_register_os_settings_rest_routes() { |
| 1033 |
register_rest_route( |
| 1034 |
'desktop-mode/v1', |
| 1035 |
'/os-settings', |
| 1036 |
array( |
| 1037 |
array( |
| 1038 |
'methods' => WP_REST_Server::READABLE, |
| 1039 |
'callback' => 'openstation_rest_get_os_settings', |
| 1040 |
'permission_callback' => 'openstation_rest_os_settings_permission', |
| 1041 |
), |
| 1042 |
array( |
| 1043 |
'methods' => WP_REST_Server::CREATABLE, |
| 1044 |
'callback' => 'openstation_rest_save_os_settings', |
| 1045 |
'permission_callback' => 'openstation_rest_os_settings_permission', |
| 1046 |
'args' => array( |
| 1047 |
'settings' => array( |
| 1048 |
'required' => true, |
| 1049 |
'type' => 'object', |
| 1050 |
), |
| 1051 |
), |
| 1052 |
), |
| 1053 |
) |
| 1054 |
); |
| 1055 |
} |
| 1056 |
add_action( 'rest_api_init', 'openstation_register_os_settings_rest_routes' ); |
| 1057 |
|
| 1058 |
/** |
| 1059 |
* Permission gate for OS settings REST routes. |
| 1060 |
* |
| 1061 |
* Requires the caller to be logged in *and* have OpenStation enabled — |
| 1062 |
* see {@see openstation_rest_require_enabled()} for why `read` alone is |
| 1063 |
* insufficient. |
| 1064 |
* |
| 1065 |
* @return true|WP_Error |
| 1066 |
*/ |
| 1067 |
function openstation_rest_os_settings_permission() { |
| 1068 |
return openstation_rest_require_enabled(); |
| 1069 |
} |
| 1070 |
|
| 1071 |
/** |
| 1072 |
* GET /desktop-mode/v1/os-settings |
| 1073 |
* |
| 1074 |
* @return WP_REST_Response |
| 1075 |
*/ |
| 1076 |
function openstation_rest_get_os_settings() { |
| 1077 |
return rest_ensure_response( openstation_get_os_settings( get_current_user_id() ) ); |
| 1078 |
} |
| 1079 |
|
| 1080 |
/** |
| 1081 |
* POST /desktop-mode/v1/os-settings |
| 1082 |
* |
| 1083 |
* Accepts a PARTIAL payload: keys the request omits keep the value |
| 1084 |
* already stored for the user, rather than resetting to the shipped |
| 1085 |
* default. The client sends only the fields that changed since its |
| 1086 |
* last confirmed save, which is what stops two open sessions from |
| 1087 |
* overwriting each other — a session that never touched the |
| 1088 |
* wallpaper cannot express an opinion about it, so a stale snapshot |
| 1089 |
* can no longer undo another session's unrelated change. |
| 1090 |
* |
| 1091 |
* A full payload still behaves exactly as before: every key is |
| 1092 |
* present, so every key wins. |
| 1093 |
* |
| 1094 |
* The merge lives here rather than in {@see openstation_save_os_settings()} |
| 1095 |
* on purpose. That function's contract is REPLACE, and migrations |
| 1096 |
* depend on it: migration 1 in `includes/migrations.php` `unset()`s |
| 1097 |
* keys and re-saves precisely so the sanitizer backfills the new |
| 1098 |
* defaults. Give the saver merge semantics and that migration |
| 1099 |
* silently becomes a no-op. |
| 1100 |
* |
| 1101 |
* Merging is shallow, one level deep. For the map-shaped fields |
| 1102 |
* (`wallpaperSettings`, `navPlacement`, `navOrder`, |
| 1103 |
* `dockPromotedPositions`) a request that sends the key replaces the |
| 1104 |
* whole map — deep-merging them would leave no way to delete an |
| 1105 |
* entry. |
| 1106 |
* |
| 1107 |
* @param WP_REST_Request $request The REST request. |
| 1108 |
* @return WP_REST_Response The saved settings (after sanitization). |
| 1109 |
*/ |
| 1110 |
function openstation_rest_save_os_settings( WP_REST_Request $request ) { |
| 1111 |
$user_id = get_current_user_id(); |
| 1112 |
$payload = $request->get_param( 'settings' ); |
| 1113 |
|
| 1114 |
// A payload that isn't an object says nothing about any field, so |
| 1115 |
// it changes nothing. The route declares `'settings' => object` |
| 1116 |
// and WP's schema validation rejects a scalar before the callback |
| 1117 |
// runs, so this is unreachable over real REST traffic — but the |
| 1118 |
// sanitizer resolves a non-array to the full defaults, which |
| 1119 |
// means the one way to reach this function with a bad payload |
| 1120 |
// used to be the one way to wipe a user's settings. Returning |
| 1121 |
// early costs nothing and keeps "don't destroy what wasn't sent" |
| 1122 |
// true of every path into this handler, not just the ones the |
| 1123 |
// schema happens to guard. |
| 1124 |
if ( ! is_array( $payload ) ) { |
| 1125 |
return rest_ensure_response( openstation_get_os_settings( $user_id ) ); |
| 1126 |
} |
| 1127 |
|
| 1128 |
// Normalize an alias-only patch before merging it with the saved master value. |
| 1129 |
if ( ! array_key_exists( 'mioEnabled', $payload ) && array_key_exists( 'mioApiEnabled', $payload ) ) { |
| 1130 |
$payload['mioEnabled'] = $payload['mioApiEnabled']; |
| 1131 |
} |
| 1132 |
|
| 1133 |
openstation_save_os_settings( |
| 1134 |
$user_id, |
| 1135 |
array_merge( openstation_get_os_settings( $user_id ), $payload ) |
| 1136 |
); |
| 1137 |
return rest_ensure_response( openstation_get_os_settings( $user_id ) ); |
| 1138 |
} |
| 1139 |
|
| 1140 |
/** |
| 1141 |
* Apply the per-user Heartbeat-rate preference to the |
| 1142 |
* `heartbeat_settings` Core filter. WordPress reads these settings |
| 1143 |
* once at page load. We set `interval` only; the allowed rates |
| 1144 |
* (15/30/45/60 s) all sit at or above Core's 15 s |
| 1145 |
* `minimalInterval` floor, so the floor never needs overriding. |
| 1146 |
* |
| 1147 |
* Only applies to users with OpenStation enabled — non-desktop |
| 1148 |
* sessions keep Core's defaults. Anonymous requests skip too. |
| 1149 |
* |
| 1150 |
* @param array $settings Filtered Heartbeat settings. |
| 1151 |
* @return array |
| 1152 |
*/ |
| 1153 |
function openstation_apply_heartbeat_rate_setting( $settings ) { |
| 1154 |
if ( ! is_array( $settings ) ) { |
| 1155 |
$settings = array(); |
| 1156 |
} |
| 1157 |
$user_id = get_current_user_id(); |
| 1158 |
if ( $user_id <= 0 ) { |
| 1159 |
return $settings; |
| 1160 |
} |
| 1161 |
if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( $user_id ) ) { |
| 1162 |
return $settings; |
| 1163 |
} |
| 1164 |
$os = openstation_get_os_settings( $user_id ); |
| 1165 |
$rate = isset( $os['heartbeatRate'] ) ? (int) $os['heartbeatRate'] : 0; |
| 1166 |
if ( ! in_array( $rate, array( 15, 30, 45, 60 ), true ) ) { |
| 1167 |
return $settings; |
| 1168 |
} |
| 1169 |
$settings['interval'] = $rate; |
| 1170 |
return $settings; |
| 1171 |
} |
| 1172 |
add_filter( 'heartbeat_settings', 'openstation_apply_heartbeat_rate_setting' ); |
| 1173 |
|