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