'galaxy', 'accent' => 'pulse', // Only read when `accent` is `custom`. Seeded with Pulse so // picking Custom before touching the wheel is a no-op rather // than a jump to black. Mirrors `DEFAULTS` in // `src/settings/constants.ts`. 'customAccent' => '#f252fc', 'dockSize' => 'default', // `round` (16px), not the preset id literally named `default`. // Preset ids are stored values and cannot be renamed, so the // option labelled "Default" in the picker is no longer the // shipped default. Must stay in step with `DEFAULTS` in // `src/settings/constants.ts` — PHP seeds the first load and JS // owns every paint after it, so a mismatch shows up as the // corners changing shape a moment after the shell boots. 'windowRadius' => 'round', // How the WordPress admin bar presents above the shell. // `hidden` ships as the default so a fresh desktop has ONE // navigation surface: everything the user can open lives on the // dock, and the dock's "Exit OpenStation" tile is the way back // to classic admin. `static` (vanilla behavior) and `dynamic` // are one pick away in OpenStation Preferences → Appearance. 'adminBarMode' => 'hidden', // Always on screen. `dynamic` (auto-hide behind a peek strip) // is one pick away in OpenStation Preferences → Appearance. 'dockBehavior' => 'static', // The Split layout's sidebar answers for itself: a folded // sidebar over a static bottom dock is a valid desk. 'sideDockBehavior' => 'static', // One dock holding every menu, with the system tiles grouped // behind a hairline. `classic` (side bar for core menus + bottom // dock for plugins) is the other option; it is no longer what a // first-run desktop looks like. 'desktopLayout' => 'unified', // Which edge the single dock sits on. Ignored by `classic`, // which derives both of its rails from the layout. 'dockPlacement' => 'bottom', 'dockRailRenderer' => 'default', // Active desktop-theme slug, or `''` for the system default. // Site-wide library (`includes/desktop-themes/`), per-user // activation. Not validated against the installed list here — // the enqueue path checks existence on every request, so a // deleted theme degrades silently instead of needing a // user-meta rewrite. 'desktopTheme' => '', // Slugs of the desktop themes whose `recommendedOsSettings` // block has already been applied for this user. A theme's // recommendations are seeded ONCE — the first time the user // activates it — and this list is the record of that. It is // what makes "never overwrite a user's later choices" true: // re-activating a theme they have worn before changes // nothing. The Themes tab's "Apply recommended layout" action // is the deliberate way back. Capped at 64 slugs. 'appliedThemeRecommendations' => array(), 'unfocusEffect' => 'darken', // Window-reveal id — the clip-path transition that uncovers a // window's content once it finishes loading. Off by default; // `none` is the plain opacity fade the shell has always had. 'windowReveal' => 'none', // Global reveal duration override in ms. 0 means "use each // reveal's own tuned timing" — the shipped reveals have // durations chosen per shape (Radar's full turn is slower // than Sweep's straight line), and one flat number would // lose that. 'windowRevealDuration' => 0, // Window-link renderer id — how relation ties between windows // are drawn (see includes/window-links.php). `svg-splines` is // the shipped built-in; `none` disables the visuals. 'windowLinkRenderer' => 'svg-splines', // When the ties are visible: 'always' (default), 'focus' (only // while a group member is focused), or 'off'. 'windowLinkVisibility' => 'always', // Master switch for the window-links feature (OS Settings → // Features). Off unmounts the visuals AND the group behaviors // below; the style knobs above keep their values for when it // comes back on. 'windowLinksEnabled' => true, // Focusing a relation-group member raises its related windows // to just below it (silent restack, no focus theft). 'windowLinkRaiseOnFocus' => true, // Related windows of the focused member get a subtle outline. 'windowLinkHighlight' => true, 'customGradient' => array( 'from' => '#2271b1', 'to' => '#7c3aed', 'angle' => 135, ), 'customImage' => null, // Per-wallpaper settings bags, keyed by wallpaper id — the // values a wallpaper's `renderConfig` dialog writes (e.g. the // Snow wallpaper's wind / particle count / flake size / // background). Scalar values only; the wallpaper owns the keys' // meaning. Missing ids mean "never configured" — the wallpaper // falls back to its defaults. Capped at 64 wallpapers × 32 keys. 'wallpaperSettings' => array(), 'libraryHdOnly' => true, 'ai' => array( 'enabled' => false, // AI assistant is opt-in; enabled from OS Settings → Features once a provider is configured. ), // Per-user opt-IN for the native Posts window. When true, // clicking the Posts dock tile opens the ``-driven // native window instead of the chromeless `edit.php` iframe. // Default OFF — the native windows are opt-in // Beta. Fresh installs land on the classic iframe; users turn // this on in OS Settings → Features → Beta features to try it. // Per-user override of the WordPress Heartbeat interval, in // seconds. 60s matches Core's "idle" default; the allowed // rates (15/30/45/60) all sit at or above Core's 15 s // `minimalInterval` floor. See // `openstation_apply_heartbeat_rate_setting` for the // `heartbeat_settings` filter that applies this. 'heartbeatRate' => 60, 'nativePostsEnabled' => false, // Per-user list of column keys hidden in the native Posts // window (e.g. array( 'author', 'tags' )). Empty array means // every column is visible. The sticky 'title' column is always // shown — the UI prevents toggling it. 'nativePostsHiddenColumns' => array(), // Per-user opt-IN for the native Pages window. Same posture as // nativePostsEnabled — defaults OFF (Beta), users opt in to swap // the classic `edit.php?post_type=page` iframe for the native UI. 'nativePagesEnabled' => false, // Per-user opt-IN for the native Users window. Defaults OFF // (Beta); the server-side cap gate (`list_users`) means the // toggle only matters for users who could see the Users tile. 'nativeUsersEnabled' => false, // Per-user opt-IN for the native Plugins window. Defaults OFF // (Beta); the server-side cap gate (`activate_plugins`) means // the toggle only matters for users who could see the Plugins // tile anyway. When `false`, the dock click uses the classic // `plugins.php` chromeless iframe path. 'nativePluginsEnabled' => false, // Per-user opt-IN for the native Comments window. Defaults OFF // (Beta); the server-side cap gate (`edit_posts`) means the // toggle only matters for users who could see the Comments tile. 'nativeCommentsEnabled' => false, // Per-user opt-IN for Station Home, the native Dashboard // window. Defaults OFF: the ordinary `index.php` Dashboard // (including any custom dashboard a plugin builds there) opens // as a chromeless iframe until the user opts in via OS // Settings → Features → Beta features. 'stationHomeEnabled' => false, // Performance enhancements are enabled unless explicitly disabled. 'adminAssetCacheEnabled' => true, 'windowPrewarmEnabled' => true, // When true, left-clicking the empty wallpaper triggers the // "Show desktop" toggle (macOS-style) and the matching entry is // hidden from the wallpaper context menu. When false (default), // the entry stays in the menu and left clicks on the wallpaper // do nothing. Per-user. 'showDesktopOnWallpaperClick' => false, // Whether the close-all-windows shortcut (Alt+Cmd/Ctrl+W) asks // before it closes. The dialog's "Don't ask again" checkbox is // what writes false; OpenStation Preferences -> Windows is what // turns it back on. Per-user. 'confirmCloseAllWindows' => true, // Mio — a soft-body companion that floats over // the wallpaper, settles onto nearby windows, and watches the // pointer. Off by default; toggled from the wallpaper context // menu. Per-user. See `docs/mio.md`. 'mioEnabled' => false, // The user's own Mio, as built in "Make it yours": partial // appearance + silhouette overrides, both empty until they // touch a control. Stored per user rather than per browser // because it is a preference about the person — ten minutes // spent building a companion should be waiting on their phone. // Sanitized by `openstation_sanitize_mio_look()`; the ranges // are enforced client-side in `sanitizeMioConfig()`. 'mioStyle' => array( 'appearance' => array(), 'physics' => array(), ), // Diagonal corner ribbon on My WordPress tiles whose post // status isn't `publish` (draft / pending / private / // scheduled). On by default — surfaces unpublished work at // a glance. Per-user. 'showPostStatusRibbons' => true, // Unlocks developer-facing surfaces meant for plugin // authors: the Starter Widget appears in the add-widget // picker, the OS Settings → Components tab runs its // intentional missing-import-warner demo, and the Code Blue // error-log reader registers (icon, window, REST routes). // Off by default. Per-user. 'developerModeEnabled' => false, // Per-user opt-OUT for the folder-sharing feature. Defaults // ON. When false: // - The Share button, share-settings modal, "Leave shared // folder" entry, and pending-invite prompt are all // suppressed in the user's shell. // - The heartbeat skips the `shares.pending` payload for // this user so they never see invites land. // - REST share routes return 404 for this user — they // can't list, invite, accept, deny, or leave. // Sites that don't want the feature (solo admin, no // collaborators) can flip the toggle and the surface // disappears without any database changes. The site-wide // "Delete folder sharing data" action in OS Settings → // Features → Advanced is a separate destructive cleanup. 'foldersSharingEnabled' => true, // Per-item navigation placement. Map of item id → one of: // 'both' — show on a rail and on the desktop. // 'rail' — show only on a rail: the dock, or the sidebar // for a Core admin menu in the split layout. // 'desktop' — show only on the wallpaper. // 'hidden' — hide from every shell surface. // Missing keys mean "no override" — the item takes the default // for its kind, which lives in `src/nav/defaults.ts`. // Sanitized as map. Capped at 256 entries. 'navPlacement' => array(), // Per-user ordering, flat across every dock/sidebar zone. Ids // not in the list keep their registration order and render // after the listed ones. Unknown ids are tolerated. 'navOrder' => array(), // Which experience the shell renders: 'auto' follows the // viewport, 'desktop' / 'mobile' force it. See `includes/mobile.php`. 'mobileLayout' => 'auto', // Ids pinned to the phone tab bar, at most three. Empty means // the server default (`openstation_mobile_tab_bar`). 'mobileTabs' => array(), // Persisted desktop position for every item the user has // promoted to the wallpaper via `navPlacement[id]=desktop|both`. // Keyed by item id, value is `{ x: int, y: int }`. The JS // synthesizer reads this when building a synthetic placement so // the icon lands where the user last dragged it instead of // resetting to (0, 0) on every reload. Capped at 256 entries. 'dockPromotedPositions' => array(), ); } /** * Retrieves the saved OS settings for a user. * * Always returns a fully-shaped array so the JS side doesn't need to * defend against partial or missing keys. * * @param int $user_id The user ID. * @return array */ function openstation_get_os_settings( $user_id ) { $user_id = (int) $user_id; if ( $user_id <= 0 ) { return openstation_sanitize_os_settings( array() ); } $raw = get_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true ); if ( ! is_array( $raw ) ) { return openstation_sanitize_os_settings( array() ); } return openstation_sanitize_os_settings( $raw ); } /** * Saves sanitized OS settings for a user. * * @param int $user_id The user ID. * @param mixed $settings Raw settings payload from the client. * @return bool True on success, false otherwise. */ function openstation_save_os_settings( $user_id, $settings ) { $user_id = (int) $user_id; if ( $user_id <= 0 ) { return false; } $clean = openstation_sanitize_os_settings( $settings ); return false !== update_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, $clean ); } /** * Strip the rail-synthesis prefix an id could carry before the * navigation model. * * `dock:` / `desktop:` used to mean "this tile is a copy of an * item whose real home is the other rail". Nothing synthesizes copies * any more — an item is one item wherever it is painted — so the * prefix is noise, and left in place it would key a preference to an * id nothing registers. * * @param string $id Possibly-prefixed id. * @return string Canonical id. */ function openstation_canonical_nav_id( $id ) { $id = (string) $id; if ( 0 === strpos( $id, 'dock:' ) ) { return substr( $id, 5 ); } if ( 0 === strpos( $id, 'desktop:' ) ) { return substr( $id, 8 ); } return $id; } /** * Carry a pre-navigation `itemVisibility` map into `navPlacement`. * * The only value that moves is `'dock'` → `'rail'`: the stored name * is now the REGION rather than a rail, so a Core admin menu the user * kept on a rail follows the layout into the sidebar instead of * needing a second migration the first time they switch. * * Runs on read (see {@see openstation_sanitize_os_settings()}) rather * than as a numbered migration, because OS settings are per-user meta * and a site with many users would pay for a sweep that the next save * performs for free. * * @param array $visibility Legacy map of item id → placement. * @return array Map of canonical item id → nav placement. */ function openstation_migrate_item_visibility( $visibility ) { $map = array( 'dock' => 'rail', 'desktop' => 'desktop', 'both' => 'both', 'hidden' => 'hidden', ); $out = array(); foreach ( (array) $visibility as $key => $val ) { if ( ! is_string( $key ) || ! is_string( $val ) || ! isset( $map[ $val ] ) ) { continue; } $id = openstation_canonical_nav_id( $key ); if ( '' === $id ) { continue; } // A prefixed and an unprefixed key can collapse onto the same // id. The unprefixed one is the item's own preference rather // than a synthesized copy's, so it wins whichever order they // arrive in. if ( $id === $key || ! isset( $out[ $id ] ) ) { $out[ $id ] = $map[ $val ]; } } return $out; } /** * Sanitizes a raw OS settings payload. * * Unknown keys are ignored; known keys are coerced field-by-field so a * partial save (e.g., only accent changed) merges cleanly with the * defaults rather than wiping unset fields. * * @param mixed $raw Raw settings from the client or user meta. * @return array Sanitized settings. */ function openstation_sanitize_os_settings( $raw ) { $defaults = openstation_default_os_settings(); if ( ! is_array( $raw ) ) { $raw = array(); } // Wallpaper — any non-empty string; registry membership is validated // client-side at apply time. $wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] ) && '' !== $raw['wallpaper'] ? sanitize_key( $raw['wallpaper'] ) : $defaults['wallpaper']; // Accent — non-empty string; swatch validity is enforced in the picker. $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent'] ? sanitize_key( $raw['accent'] ) : $defaults['accent']; // The colour behind the Custom swatch. A full `#rrggbb` triplet and // nothing else: `sanitize_hex_color()` would also pass `#abc`, which // the client-side parser rejects, and a value that survives the save // only to be dropped on load is worse than one refused here. $custom_accent = isset( $raw['customAccent'] ) && is_string( $raw['customAccent'] ) && preg_match( '/^#[0-9a-fA-F]{6}$/', $raw['customAccent'] ) ? strtolower( $raw['customAccent'] ) : $defaults['customAccent']; // Dock size — must be one of the three known values. $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], OPENSTATION_OS_SETTINGS_DOCK_SIZES, true ) ? (string) $raw['dockSize'] : $defaults['dockSize']; // Window radius — must be one of the three known values. $window_radius = isset( $raw['windowRadius'] ) && in_array( $raw['windowRadius'], OPENSTATION_OS_SETTINGS_WINDOW_RADII, true ) ? (string) $raw['windowRadius'] : $defaults['windowRadius']; // Admin-bar mode — must be one of the three known values. $admin_bar_mode = isset( $raw['adminBarMode'] ) && in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true ) ? (string) $raw['adminBarMode'] : $defaults['adminBarMode']; // Dock behavior — must be one of the two known values. One answer // per rail: the dock, and the Split layout's sidebar. $dock_behavior = isset( $raw['dockBehavior'] ) && in_array( $raw['dockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true ) ? (string) $raw['dockBehavior'] : $defaults['dockBehavior']; $side_dock_behavior = isset( $raw['sideDockBehavior'] ) && in_array( $raw['sideDockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true ) ? (string) $raw['sideDockBehavior'] : $defaults['sideDockBehavior']; // Desktop layout — must be one of the known values (`classic`, // `unified`). Default `unified`. $desktop_layout = isset( $raw['desktopLayout'] ) && in_array( $raw['desktopLayout'], OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS, true ) ? (string) $raw['desktopLayout'] : $defaults['desktopLayout']; // Dock placement — which edge the single dock sits on. Must be one // of the three known values (`bottom`, `left`, `right`). $dock_placement = isset( $raw['dockPlacement'] ) && in_array( $raw['dockPlacement'], OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS, true ) ? (string) $raw['dockPlacement'] : $defaults['dockPlacement']; // Dock rail renderer id — accept any sanitize_key()-clean // string. JS-side registry resolves at use time and falls back // to `'default'` when the picked renderer isn't registered. $dock_rail_renderer = $defaults['dockRailRenderer']; if ( isset( $raw['dockRailRenderer'] ) && is_string( $raw['dockRailRenderer'] ) ) { $slug = sanitize_key( $raw['dockRailRenderer'] ); if ( '' !== $slug ) { $dock_rail_renderer = $slug; } } // Desktop theme slug — a pattern check, NOT an allow-list, the // same idiom as `dockRailRenderer` above. Validating against the // installed-theme option here would load (and unserialize) that // option on every single settings write for a value the enqueue // path re-checks anyway. `''` is the system default and is a // legitimate value, so an empty/absent key keeps the default. $desktop_theme = $defaults['desktopTheme']; if ( isset( $raw['desktopTheme'] ) && is_string( $raw['desktopTheme'] ) ) { $desktop_theme = sanitize_key( $raw['desktopTheme'] ); } // appliedThemeRecommendations — list of desktop-theme slugs whose // recommendations this user has already been seeded with. Unknown // slugs are kept (a deleted-then-reinstalled theme must not // re-seed and clobber the settings the user has since chosen). $applied_theme_recommendations = $defaults['appliedThemeRecommendations']; if ( isset( $raw['appliedThemeRecommendations'] ) && is_array( $raw['appliedThemeRecommendations'] ) ) { $applied_theme_recommendations = array(); foreach ( $raw['appliedThemeRecommendations'] as $theme_slug ) { if ( ! is_string( $theme_slug ) || '' === $theme_slug ) { continue; } $theme_slug = sanitize_key( $theme_slug ); if ( '' === $theme_slug ) { continue; } $applied_theme_recommendations[] = $theme_slug; } // Keep the MOST RECENT 64, not the first 64 — the client // appends, so trimming from the front would silently discard // the entry that was just written and let the theme re-seed on // the next activation. $applied_theme_recommendations = array_slice( array_values( array_unique( $applied_theme_recommendations ) ), -64 ); } // Unfocus effect id — accept the `none` sentinel or any registry id. // Effect ids mirror the JS registry pattern `^[a-z0-9_/-]+$` (slashes // allowed for `vendor/sub-id` namespacing), so we lower-case and strip // to that charset rather than using sanitize_key() (which would drop // the slash and break a namespaced id on round-trip). No allow-list: // the JS engine resolves at use time and treats an unknown id as "no // effect". $unfocus_effect = $defaults['unfocusEffect']; if ( isset( $raw['unfocusEffect'] ) && is_string( $raw['unfocusEffect'] ) ) { $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['unfocusEffect'] ) ); if ( '' !== $slug ) { $unfocus_effect = $slug; } } // Window-reveal id — same id charset and same no-allow-list // reasoning as the unfocus effect above. The JS surface resolves at // play time and treats an unknown id as "no reveal", so a reveal // belonging to a temporarily-deactivated plugin survives the // round-trip and starts working again the moment it re-registers. $window_reveal = $defaults['windowReveal']; if ( isset( $raw['windowReveal'] ) && is_string( $raw['windowReveal'] ) ) { $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowReveal'] ) ); if ( '' !== $slug ) { $window_reveal = $slug; } } // Window-reveal duration override — 0 (the default) means "leave // each reveal's own timing alone". Anything else is clamped into // the playable range rather than rejected: a value past the end of // the range still expresses a direction, and the nearest playable // duration is the honest reading of it. $window_reveal_duration = $defaults['windowRevealDuration']; if ( isset( $raw['windowRevealDuration'] ) && is_numeric( $raw['windowRevealDuration'] ) ) { $requested = (int) round( (float) $raw['windowRevealDuration'] ); if ( $requested > 0 ) { $window_reveal_duration = max( OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN, min( OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX, $requested ) ); } else { $window_reveal_duration = 0; } } // Window-link renderer id — same id charset as unfocus effects // (slashes allowed for `vendor/sub-id`). No allow-list: the JS // render host resolves at use time and falls back to the built-in // `svg-splines` when the picked renderer isn't registered. $window_link_renderer = $defaults['windowLinkRenderer']; if ( isset( $raw['windowLinkRenderer'] ) && is_string( $raw['windowLinkRenderer'] ) ) { $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowLinkRenderer'] ) ); if ( '' !== $slug ) { $window_link_renderer = $slug; } } // Window-link visibility — small closed set. $window_link_visibility = $defaults['windowLinkVisibility']; if ( isset( $raw['windowLinkVisibility'] ) && in_array( $raw['windowLinkVisibility'], array( 'focus', 'always', 'off' ), true ) ) { $window_link_visibility = $raw['windowLinkVisibility']; } // Window-links feature switches — plain booleans. $window_links_enabled = isset( $raw['windowLinksEnabled'] ) ? (bool) $raw['windowLinksEnabled'] : $defaults['windowLinksEnabled']; $window_link_raise_on_focus = isset( $raw['windowLinkRaiseOnFocus'] ) ? (bool) $raw['windowLinkRaiseOnFocus'] : $defaults['windowLinkRaiseOnFocus']; $window_link_highlight = isset( $raw['windowLinkHighlight'] ) ? (bool) $raw['windowLinkHighlight'] : $defaults['windowLinkHighlight']; // Custom gradient — { from, to: valid hex; angle: int 0–360 }. $custom_gradient = $defaults['customGradient']; if ( isset( $raw['customGradient'] ) && is_array( $raw['customGradient'] ) ) { $cg = $raw['customGradient']; if ( isset( $cg['from'] ) && is_string( $cg['from'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['from'] ) ) { $custom_gradient['from'] = strtolower( $cg['from'] ); } if ( isset( $cg['to'] ) && is_string( $cg['to'] ) && preg_match( '/^#[0-9a-f]{3,8}$/i', $cg['to'] ) ) { $custom_gradient['to'] = strtolower( $cg['to'] ); } if ( isset( $cg['angle'] ) && is_numeric( $cg['angle'] ) ) { $angle = (int) $cg['angle']; if ( $angle >= 0 && $angle <= 360 ) { $custom_gradient['angle'] = $angle; } } } // Custom image — { id: positive int, url: valid https? URL } or null. $custom_image = null; if ( isset( $raw['customImage'] ) && is_array( $raw['customImage'] ) ) { $ci = $raw['customImage']; $ci_id = isset( $ci['id'] ) && is_numeric( $ci['id'] ) ? (int) $ci['id'] : 0; $ci_url = isset( $ci['url'] ) ? esc_url_raw( (string) $ci['url'] ) : ''; if ( $ci_id > 0 && '' !== $ci_url && preg_match( '/^https?:\/\//i', $ci_url ) ) { $custom_image = array( 'id' => $ci_id, 'url' => $ci_url, ); } } // wallpaperSettings — map>. Wallpaper // ids follow the same charset as unfocus-effect ids (slashes allowed // for `vendor/sub-id` namespacing); setting keys follow the JS // identifier-ish charset wallpaper authors use (camelCase, hyphens, // underscores). Values must be scalar — booleans and numbers pass // through typed, strings are sanitized and length-capped. Unknown // wallpaper ids are kept (a deactivated wallpaper plugin's settings // should survive reactivation). Capped at 64 ids × 32 keys. $wallpaper_settings = array(); if ( isset( $raw['wallpaperSettings'] ) && is_array( $raw['wallpaperSettings'] ) ) { $id_count = 0; foreach ( $raw['wallpaperSettings'] as $wp_id => $bag ) { if ( $id_count >= 64 ) { break; } if ( ! is_string( $wp_id ) || '' === $wp_id || ! is_array( $bag ) ) { continue; } $wp_slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $wp_id ) ); if ( '' === $wp_slug ) { continue; } $clean_bag = array(); $key_count = 0; foreach ( $bag as $key => $value ) { if ( $key_count >= 32 ) { break; } if ( ! is_string( $key ) || '' === $key || ! preg_match( '/^[a-zA-Z0-9_-]+$/', $key ) ) { continue; } if ( is_bool( $value ) ) { $clean_bag[ $key ] = $value; } elseif ( is_int( $value ) || is_float( $value ) ) { if ( ! is_finite( (float) $value ) ) { continue; } $clean_bag[ $key ] = $value; } elseif ( is_string( $value ) ) { $clean_bag[ $key ] = mb_substr( sanitize_text_field( $value ), 0, 256 ); } else { continue; } ++$key_count; } if ( empty( $clean_bag ) ) { continue; } $wallpaper_settings[ $wp_slug ] = $clean_bag; ++$id_count; } } // Library HD only — boolean. $library_hd_only = isset( $raw['libraryHdOnly'] ) ? (bool) $raw['libraryHdOnly'] : $defaults['libraryHdOnly']; // AI settings — just the per-user on/off toggle. Provider + model selection // is delegated to the Core AI Client, so there is no preference to persist. $ai = $defaults['ai']; if ( isset( $raw['ai'] ) && is_array( $raw['ai'] ) ) { $raw_ai = $raw['ai']; if ( isset( $raw_ai['enabled'] ) ) { $ai['enabled'] = (bool) $raw_ai['enabled']; } } // Heartbeat rate — one of the four allowed values. The PHP // filter `openstation_apply_heartbeat_rate_setting` reads // this and passes it through to `heartbeat_settings` so // WordPress Core itself reduces the interval on the next page // load. 5 s is intentionally excluded: Core's // `minimalInterval` floor clamps anything below 15 back up to // 15 unless every upstream filter cooperates, and the gain // over 15 s is marginal. $allowed_heartbeat_rates = array( 15, 30, 45, 60 ); $heartbeat_rate = $defaults['heartbeatRate']; if ( isset( $raw['heartbeatRate'] ) && is_numeric( $raw['heartbeatRate'] ) ) { $candidate = (int) $raw['heartbeatRate']; if ( in_array( $candidate, $allowed_heartbeat_rates, true ) ) { $heartbeat_rate = $candidate; } } $native_posts_enabled = isset( $raw['nativePostsEnabled'] ) ? (bool) $raw['nativePostsEnabled'] : $defaults['nativePostsEnabled']; $native_posts_hidden_columns = $defaults['nativePostsHiddenColumns']; if ( isset( $raw['nativePostsHiddenColumns'] ) && is_array( $raw['nativePostsHiddenColumns'] ) ) { $native_posts_hidden_columns = array(); foreach ( $raw['nativePostsHiddenColumns'] as $col ) { if ( ! is_string( $col ) || '' === $col ) { continue; } $slug = sanitize_key( $col ); if ( '' === $slug ) { continue; } $native_posts_hidden_columns[] = $slug; } // Cap to a sane upper bound — far more than any plausible // column count, but blocks a malicious payload from bloating // user meta indefinitely. $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 ); } $native_pages_enabled = isset( $raw['nativePagesEnabled'] ) ? (bool) $raw['nativePagesEnabled'] : $defaults['nativePagesEnabled']; $native_users_enabled = isset( $raw['nativeUsersEnabled'] ) ? (bool) $raw['nativeUsersEnabled'] : $defaults['nativeUsersEnabled']; $native_plugins_enabled = isset( $raw['nativePluginsEnabled'] ) ? (bool) $raw['nativePluginsEnabled'] : $defaults['nativePluginsEnabled']; $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] ) ? (bool) $raw['nativeCommentsEnabled'] : $defaults['nativeCommentsEnabled']; $station_home_enabled = isset( $raw['stationHomeEnabled'] ) ? (bool) $raw['stationHomeEnabled'] : $defaults['stationHomeEnabled']; // Site-wide performance controls supersede the legacy per-user values. // Keep the snapshot keys so existing window consumers can read them. $extended_options = openstation_get_extended_options(); $admin_asset_cache_enabled = $extended_options['admin_asset_cache']; $window_prewarm_enabled = $extended_options['window_prewarm']; $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] ) ? (bool) $raw['showDesktopOnWallpaperClick'] : $defaults['showDesktopOnWallpaperClick']; $confirm_close_all_windows = isset( $raw['confirmCloseAllWindows'] ) ? (bool) $raw['confirmCloseAllWindows'] : $defaults['confirmCloseAllWindows']; $mio_enabled = isset( $raw['mioEnabled'] ) ? (bool) $raw['mioEnabled'] : $defaults['mioEnabled']; // A missing key means "no look saved yet", which sanitizes to the // same pair of empty arrays the defaults carry — so this needs no // isset() branch of its own. $mio_style = openstation_sanitize_mio_look( isset( $raw['mioStyle'] ) ? $raw['mioStyle'] : null ); $show_post_status_ribbons = isset( $raw['showPostStatusRibbons'] ) ? (bool) $raw['showPostStatusRibbons'] : $defaults['showPostStatusRibbons']; $developer_mode_enabled = isset( $raw['developerModeEnabled'] ) ? (bool) $raw['developerModeEnabled'] : $defaults['developerModeEnabled']; // mobileLayout — the phone/desktop override. $mobile_layout = isset( $raw['mobileLayout'] ) && in_array( $raw['mobileLayout'], OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS, true ) ? (string) $raw['mobileLayout'] : $defaults['mobileLayout']; // mobileTabs — ordered nav ids pinned to the phone tab bar. Same // id grammar as navOrder, capped at the tab bar's slot count. $mobile_tabs = array(); if ( isset( $raw['mobileTabs'] ) && is_array( $raw['mobileTabs'] ) ) { $seen_tabs = array(); foreach ( $raw['mobileTabs'] as $id ) { if ( ! is_string( $id ) || '' === $id ) { continue; } $slug = sanitize_key( openstation_canonical_nav_id( $id ) ); if ( '' === $slug || isset( $seen_tabs[ $slug ] ) ) { continue; } $seen_tabs[ $slug ] = true; $mobile_tabs[] = $slug; if ( count( $mobile_tabs ) >= OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX ) { break; } } } $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] ) ? (bool) $raw['foldersSharingEnabled'] : $defaults['foldersSharingEnabled']; // navPlacement — map. Unknown ids are kept // (a deactivated plugin's setting should survive reactivation); // invalid placement values are dropped. // // Reads the pre-navigation `itemVisibility` map when this user has // no `navPlacement` yet, so an existing arrangement carries over on // first load and is written back on the next save. See // `openstation_migrate_item_visibility()`. $raw_placement = array(); if ( isset( $raw['navPlacement'] ) && is_array( $raw['navPlacement'] ) ) { $raw_placement = $raw['navPlacement']; } elseif ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) { $raw_placement = openstation_migrate_item_visibility( $raw['itemVisibility'] ); } $nav_placement = array(); if ( ! empty( $raw_placement ) ) { $allowed_placements = array( 'both', 'rail', 'desktop', 'hidden' ); $count = 0; foreach ( $raw_placement as $key => $val ) { if ( $count >= 256 ) { break; } if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) { continue; } $slug = sanitize_key( $key ); if ( '' === $slug ) { continue; } if ( ! in_array( $val, $allowed_placements, true ) ) { continue; } $nav_placement[ $slug ] = $val; ++$count; } } // navOrder — ordered list of item ids, flat across every zone. // Reads the pre-navigation `dockOrder` when absent, stripping the // rail-synthesis prefixes (`dock:` / `desktop:`) that model no // longer has. $raw_order = array(); if ( isset( $raw['navOrder'] ) && is_array( $raw['navOrder'] ) ) { $raw_order = $raw['navOrder']; } elseif ( isset( $raw['dockOrder'] ) && is_array( $raw['dockOrder'] ) ) { $raw_order = $raw['dockOrder']; } $nav_order = array(); if ( ! empty( $raw_order ) ) { $seen = array(); foreach ( $raw_order as $id ) { if ( ! is_string( $id ) || '' === $id ) { continue; } $slug = sanitize_key( openstation_canonical_nav_id( $id ) ); if ( '' === $slug || isset( $seen[ $slug ] ) ) { continue; } $seen[ $slug ] = true; $nav_order[] = $slug; if ( count( $nav_order ) >= 256 ) { break; } } } // dockPromotedPositions — map. // Persisted positions for synthetic dock-promoted placements, so // the JS synthesizer can restore the user's manual placement on // next reload. Capped at 256; absurd coordinates are dropped. $dock_promoted_positions = array(); if ( isset( $raw['dockPromotedPositions'] ) && is_array( $raw['dockPromotedPositions'] ) ) { $count = 0; $max_coord = 100000; // generous; real screens stop in the thousands. foreach ( $raw['dockPromotedPositions'] as $key => $val ) { if ( $count >= 256 ) { break; } if ( ! is_string( $key ) || '' === $key ) { continue; } $slug = sanitize_key( $key ); if ( '' === $slug ) { continue; } if ( ! is_array( $val ) ) { continue; } if ( ! isset( $val['x'] ) || ! isset( $val['y'] ) ) { continue; } $x = is_numeric( $val['x'] ) ? (int) $val['x'] : null; $y = is_numeric( $val['y'] ) ? (int) $val['y'] : null; if ( null === $x || null === $y ) { continue; } if ( abs( $x ) > $max_coord || abs( $y ) > $max_coord ) { continue; } $dock_promoted_positions[ $slug ] = array( 'x' => $x, 'y' => $y, ); ++$count; } } return array( 'wallpaper' => $wallpaper, 'accent' => $accent, 'customAccent' => $custom_accent, 'dockSize' => $dock_size, 'windowRadius' => $window_radius, 'adminBarMode' => $admin_bar_mode, 'desktopLayout' => $desktop_layout, 'dockPlacement' => $dock_placement, 'dockBehavior' => $dock_behavior, 'sideDockBehavior' => $side_dock_behavior, 'dockRailRenderer' => $dock_rail_renderer, 'desktopTheme' => $desktop_theme, 'appliedThemeRecommendations' => $applied_theme_recommendations, 'unfocusEffect' => $unfocus_effect, 'windowReveal' => $window_reveal, 'windowRevealDuration' => $window_reveal_duration, 'windowLinkRenderer' => $window_link_renderer, 'windowLinkVisibility' => $window_link_visibility, 'windowLinksEnabled' => $window_links_enabled, 'windowLinkRaiseOnFocus' => $window_link_raise_on_focus, 'windowLinkHighlight' => $window_link_highlight, 'customGradient' => $custom_gradient, 'customImage' => $custom_image, 'wallpaperSettings' => $wallpaper_settings, 'libraryHdOnly' => $library_hd_only, 'ai' => $ai, 'heartbeatRate' => $heartbeat_rate, 'nativePostsEnabled' => $native_posts_enabled, 'nativePostsHiddenColumns' => $native_posts_hidden_columns, 'nativePagesEnabled' => $native_pages_enabled, 'nativeUsersEnabled' => $native_users_enabled, 'nativePluginsEnabled' => $native_plugins_enabled, 'nativeCommentsEnabled' => $native_comments_enabled, 'stationHomeEnabled' => $station_home_enabled, 'adminAssetCacheEnabled' => $admin_asset_cache_enabled, 'windowPrewarmEnabled' => $window_prewarm_enabled, 'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click, 'confirmCloseAllWindows' => $confirm_close_all_windows, 'mioEnabled' => $mio_enabled, 'mioStyle' => $mio_style, 'showPostStatusRibbons' => $show_post_status_ribbons, 'developerModeEnabled' => $developer_mode_enabled, 'foldersSharingEnabled' => $folders_sharing_enabled, 'navPlacement' => $nav_placement, 'navOrder' => $nav_order, 'mobileLayout' => $mobile_layout, 'mobileTabs' => $mobile_tabs, 'dockPromotedPositions' => $dock_promoted_positions, ); } /** * Registers the REST routes for OS settings. */ function openstation_register_os_settings_rest_routes() { register_rest_route( 'desktop-mode/v1', '/os-settings', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'openstation_rest_get_os_settings', 'permission_callback' => 'openstation_rest_os_settings_permission', ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'openstation_rest_save_os_settings', 'permission_callback' => 'openstation_rest_os_settings_permission', 'args' => array( 'settings' => array( 'required' => true, 'type' => 'object', ), ), ), ) ); } add_action( 'rest_api_init', 'openstation_register_os_settings_rest_routes' ); /** * Permission gate for OS settings REST routes. * * Requires the caller to be logged in *and* have OpenStation enabled — * see {@see openstation_rest_require_enabled()} for why `read` alone is * insufficient. * * @return true|WP_Error */ function openstation_rest_os_settings_permission() { return openstation_rest_require_enabled(); } /** * GET /desktop-mode/v1/os-settings * * @return WP_REST_Response */ function openstation_rest_get_os_settings() { return rest_ensure_response( openstation_get_os_settings( get_current_user_id() ) ); } /** * POST /desktop-mode/v1/os-settings * * Accepts a PARTIAL payload: keys the request omits keep the value * already stored for the user, rather than resetting to the shipped * default. The client sends only the fields that changed since its * last confirmed save, which is what stops two open sessions from * overwriting each other — a session that never touched the * wallpaper cannot express an opinion about it, so a stale snapshot * can no longer undo another session's unrelated change. * * A full payload still behaves exactly as before: every key is * present, so every key wins. * * The merge lives here rather than in {@see openstation_save_os_settings()} * on purpose. That function's contract is REPLACE, and migrations * depend on it: migration 1 in `includes/migrations.php` `unset()`s * keys and re-saves precisely so the sanitizer backfills the new * defaults. Give the saver merge semantics and that migration * silently becomes a no-op. * * Merging is shallow, one level deep. For the map-shaped fields * (`wallpaperSettings`, `navPlacement`, `navOrder`, * `dockPromotedPositions`) a request that sends the key replaces the * whole map — deep-merging them would leave no way to delete an * entry. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response The saved settings (after sanitization). */ function openstation_rest_save_os_settings( WP_REST_Request $request ) { $user_id = get_current_user_id(); $payload = $request->get_param( 'settings' ); // A payload that isn't an object says nothing about any field, so // it changes nothing. The route declares `'settings' => object` // and WP's schema validation rejects a scalar before the callback // runs, so this is unreachable over real REST traffic — but the // sanitizer resolves a non-array to the full defaults, which // means the one way to reach this function with a bad payload // used to be the one way to wipe a user's settings. Returning // early costs nothing and keeps "don't destroy what wasn't sent" // true of every path into this handler, not just the ones the // schema happens to guard. if ( ! is_array( $payload ) ) { return rest_ensure_response( openstation_get_os_settings( $user_id ) ); } openstation_save_os_settings( $user_id, array_merge( openstation_get_os_settings( $user_id ), $payload ) ); return rest_ensure_response( openstation_get_os_settings( $user_id ) ); } /** * Apply the per-user Heartbeat-rate preference to the * `heartbeat_settings` Core filter. WordPress reads these settings * once at page load. We set `interval` only; the allowed rates * (15/30/45/60 s) all sit at or above Core's 15 s * `minimalInterval` floor, so the floor never needs overriding. * * Only applies to users with OpenStation enabled — non-desktop * sessions keep Core's defaults. Anonymous requests skip too. * * @param array $settings Filtered Heartbeat settings. * @return array */ function openstation_apply_heartbeat_rate_setting( $settings ) { if ( ! is_array( $settings ) ) { $settings = array(); } $user_id = get_current_user_id(); if ( $user_id <= 0 ) { return $settings; } if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( $user_id ) ) { return $settings; } $os = openstation_get_os_settings( $user_id ); $rate = isset( $os['heartbeatRate'] ) ? (int) $os['heartbeatRate'] : 0; if ( ! in_array( $rate, array( 15, 30, 45, 60 ), true ) ) { return $settings; } $settings['interval'] = $rate; return $settings; } add_filter( 'heartbeat_settings', 'openstation_apply_heartbeat_rate_setting' );