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 +370 -49 1.0.11.1.10 View file →
@@ -40,11 +40,40 @@
40 40 */
41 41 const OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES = array( 'static', 'dynamic', 'hidden' );
42 42
43 43 /** Valid desktop-layout IDs — mirrors the TS `DESKTOP_LAYOUTS` constant. */
44 -const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified', 'spatial' );
44 +const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified' );
45 45
46 46 /**
47 + * Valid dock-placement IDs — mirrors the TS `DOCK_PLACEMENTS` constant.
48 + *
49 + * Which edge the single dock sits on. Read by the layout dispatcher for
50 + * `unified`; `classic` derives its two rails from the layout itself and
51 + * ignores this.
52 + */
53 +const OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS = array( 'bottom', 'left', 'right' );
54 +
55 +/**
56 + * Valid dock-behavior IDs — mirrors the TS `DOCK_BEHAVIORS` constant.
57 + *
58 + * `static` keeps the dock always on screen (the default); `dynamic`
59 + * parks it off its edge behind a peek strip that reveals when the
60 + * pointer reaches that edge or something on it takes keyboard focus,
61 + * and releases the band it floats over from the work area.
62 + */
63 +const OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS = array( 'static', 'dynamic' );
64 +
65 +/**
66 + * `mobileLayout` — which experience the shell renders. `auto`
67 + * follows the viewport; the other two force it either way.
68 + * Mirrors `OsModePreference` in `src/mode/index.ts`.
69 + */
70 +const OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS = array( 'auto', 'desktop', 'mobile' );
71 +
72 +/** `mobileTabs` — at most this many ids pinned to the phone tab bar. */
73 +const OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX = 3;
74 +
75 +/**
47 76 * Playable range for the window-reveal duration override, in ms.
48 77 * Mirrors `MIN_REVEAL_DURATION_MS` / `MAX_REVEAL_DURATION_MS` in
49 78 * `src/reveals/registry.ts`.
50 79 *
@@ -64,15 +93,46 @@
64 93 */
65 94 function openstation_default_os_settings() {
66 95 return array(
67 96 'wallpaper' => 'galaxy',
97 + // Pulse, the brand's signature. Mirrors `DEFAULTS` in
98 + // `src/settings/constants.ts`.
68 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',
69 105 'dockSize' => 'default',
70 - 'windowRadius' => 'default',
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',
71 114 // How the WordPress admin bar presents above the shell.
72 - // `static` is vanilla behavior and the shipped default.
73 - 'adminBarMode' => 'static',
74 - 'desktopLayout' => 'classic',
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',
75 135 'dockRailRenderer' => 'default',
76 136 // Active desktop-theme slug, or `''` for the system default.
77 137 // Site-wide library (`includes/desktop-themes/`), per-user
78 138 // activation. Not validated against the installed list here —
@@ -152,8 +212,13 @@
152 212 // window (e.g. array( 'author', 'tags' )). Empty array means
153 213 // every column is visible. The sticky 'title' column is always
154 214 // shown — the UI prevents toggling it.
155 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(),
156 221 // Per-user opt-IN for the native Pages window. Same posture as
157 222 // nativePostsEnabled — defaults OFF (Beta), users opt in to swap
158 223 // the classic `edit.php?post_type=page` iframe for the native UI.
159 224 'nativePagesEnabled' => false,
@@ -170,8 +235,17 @@
170 235 // Per-user opt-IN for the native Comments window. Defaults OFF
171 236 // (Beta); the server-side cap gate (`edit_posts`) means the
172 237 // toggle only matters for users who could see the Comments tile.
173 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,
174 248 // When true, left-clicking the empty wallpaper triggers the
175 249 // "Show desktop" toggle (macOS-style) and the matching entry is
176 250 // hidden from the wallpaper context menu. When false (default),
177 251 // the entry stays in the menu and left clicks on the wallpaper
@@ -176,13 +250,20 @@
176 250 // hidden from the wallpaper context menu. When false (default),
177 251 // the entry stays in the menu and left clicks on the wallpaper
178 252 // do nothing. Per-user.
179 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,
180 259 // Mio — a soft-body companion that floats over
181 260 // the wallpaper, settles onto nearby windows, and watches the
182 261 // pointer. Off by default; toggled from the wallpaper context
183 262 // menu. Per-user. See `docs/mio.md`.
184 263 'mioEnabled' => false,
264 + 'mioApiEnabled' => false,
265 + 'mioShowOnWallpaper' => true,
185 266 // The user's own Mio, as built in "Make it yours": partial
186 267 // appearance + silhouette overrides, both empty until they
187 268 // touch a control. Stored per user rather than per browser
188 269 // because it is a preference about the person — ten minutes
@@ -199,11 +280,12 @@
199 280 // a glance. Per-user.
200 281 'showPostStatusRibbons' => true,
201 282 // Unlocks developer-facing surfaces meant for plugin
202 283 // authors: the Starter Widget appears in the add-widget
203 - // picker, and the OS Settings → Components tab runs its
204 - // intentional missing-import-warner demo. Off by default.
205 - // Per-user.
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.
206 288 'developerModeEnabled' => false,
207 289 // Per-user opt-OUT for the folder-sharing feature. Defaults
208 290 // ON. When false:
209 291 // - The Share button, share-settings modal, "Leave shared
@@ -218,23 +300,30 @@
218 300 // disappears without any database changes. The site-wide
219 301 // "Delete folder sharing data" action in OS Settings →
220 302 // Features → Advanced is a separate destructive cleanup.
221 303 'foldersSharingEnabled' => true,
222 - // Per-item placement preferences. Map of item id (dock-item
223 - // slug or registered desktop-icon id) → one of:
224 - // 'both' — show on both dock and desktop.
225 - // 'dock' — show only on the dock; hide from desktop.
226 - // '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.
227 309 // 'hidden' — hide from every shell surface.
228 - // 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`.
229 312 // Sanitized as map<sanitize_key, enum>. Capped at 256 entries.
230 - 'itemVisibility' => array(),
231 - // Per-user dock ordering. Ordered list of item ids; ids not in
232 - // the list keep their server-supplied position appended after
233 - // the listed ones. Unknown ids are tolerated.
234 - 'dockOrder' => array(),
235 - // Persisted desktop position for every dock item the user has
236 - // 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`.
237 326 // Keyed by item id, value is `{ x: int, y: int }`. The JS
238 327 // synthesizer reads this when building a synthetic placement so
239 328 // the icon lands where the user last dragged it instead of
240 329 // resetting to (0, 0) on every reload. Capped at 256 entries.
@@ -253,14 +342,14 @@
253 342 */
254 343 function openstation_get_os_settings( $user_id ) {
255 344 $user_id = (int) $user_id;
256 345 if ( $user_id <= 0 ) {
257 - return openstation_default_os_settings();
346 + return openstation_sanitize_os_settings( array() );
258 347 }
259 348
260 349 $raw = get_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true );
261 350 if ( ! is_array( $raw ) ) {
262 - return openstation_default_os_settings();
351 + return openstation_sanitize_os_settings( array() );
263 352 }
264 353
265 354 return openstation_sanitize_os_settings( $raw );
266 355 }
@@ -282,8 +371,76 @@
282 371 return false !== update_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, $clean );
283 372 }
284 373
285 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 +/**
286 443 * Sanitizes a raw OS settings payload.
287 444 *
288 445 * Unknown keys are ignored; known keys are coerced field-by-field so a
289 446 * partial save (e.g., only accent changed) merges cleanly with the
@@ -295,9 +452,9 @@
295 452 function openstation_sanitize_os_settings( $raw ) {
296 453 $defaults = openstation_default_os_settings();
297 454
298 455 if ( ! is_array( $raw ) ) {
299 - return $defaults;
456 + $raw = array();
300 457 }
301 458
302 459 // Wallpaper — any non-empty string; registry membership is validated
303 460 // client-side at apply time.
@@ -309,8 +466,18 @@
309 466 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
310 467 ? sanitize_key( $raw['accent'] )
311 468 : $defaults['accent'];
312 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 +
313 480 // Dock size — must be one of the three known values.
314 481 $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], OPENSTATION_OS_SETTINGS_DOCK_SIZES, true )
315 482 ? (string) $raw['dockSize']
316 483 : $defaults['dockSize'];
@@ -325,15 +492,33 @@
325 492 && in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true )
326 493 ? (string) $raw['adminBarMode']
327 494 : $defaults['adminBarMode'];
328 495
329 - // Desktop layout — must be one of the three known values
330 - // (`classic`, `unified`, `spatial`). Default `classic`.
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`.
331 509 $desktop_layout = isset( $raw['desktopLayout'] )
332 510 && in_array( $raw['desktopLayout'], OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS, true )
333 511 ? (string) $raw['desktopLayout']
334 512 : $defaults['desktopLayout'];
335 513
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 +
336 521 // Dock rail renderer id — accept any sanitize_key()-clean
337 522 // string. JS-side registry resolves at use time and falls back
338 523 // to `'default'` when the picked renderer isn't registered.
339 524 $dock_rail_renderer = $defaults['dockRailRenderer'];
@@ -600,8 +785,27 @@
600 785 // user meta indefinitely.
601 786 $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 );
602 787 }
603 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 +
604 808 $native_pages_enabled = isset( $raw['nativePagesEnabled'] )
605 809 ? (bool) $raw['nativePagesEnabled']
606 810 : $defaults['nativePagesEnabled'];
607 811
@@ -616,15 +820,29 @@
616 820 $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] )
617 821 ? (bool) $raw['nativeCommentsEnabled']
618 822 : $defaults['nativeCommentsEnabled'];
619 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 +
620 834 $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] )
621 835 ? (bool) $raw['showDesktopOnWallpaperClick']
622 836 : $defaults['showDesktopOnWallpaperClick'];
623 837
838 + $confirm_close_all_windows = isset( $raw['confirmCloseAllWindows'] )
839 + ? (bool) $raw['confirmCloseAllWindows']
840 + : $defaults['confirmCloseAllWindows'];
841 +
624 842 $mio_enabled = isset( $raw['mioEnabled'] )
625 843 ? (bool) $raw['mioEnabled']
626 - : $defaults['mioEnabled'];
844 + : ( isset( $raw['mioApiEnabled'] ) ? (bool) $raw['mioApiEnabled'] : $defaults['mioEnabled'] );
627 845
628 846 // A missing key means "no look saved yet", which sanitizes to the
629 847 // same pair of empty arrays the defaults carry — so this needs no
630 848 // isset() branch of its own.
@@ -639,20 +857,59 @@
639 857 $developer_mode_enabled = isset( $raw['developerModeEnabled'] )
640 858 ? (bool) $raw['developerModeEnabled']
641 859 : $defaults['developerModeEnabled'];
642 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 +
643 888 $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] )
644 889 ? (bool) $raw['foldersSharingEnabled']
645 890 : $defaults['foldersSharingEnabled'];
646 891
647 - // itemVisibility — map<sanitize_key, enum>. Unknown ids are kept
892 + // navPlacement — map<sanitize_key, enum>. Unknown ids are kept
648 893 // (a deactivated plugin's setting should survive reactivation);
649 894 // invalid placement values are dropped.
650 - $item_visibility = array();
651 - if ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) {
652 - $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' );
653 910 $count = 0;
654 - foreach ( $raw['itemVisibility'] as $key => $val ) {
911 + foreach ( $raw_placement as $key => $val ) {
655 912 if ( $count >= 256 ) {
656 913 break;
657 914 }
658 915 if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) {
@@ -664,34 +921,38 @@
664 921 }
665 922 if ( ! in_array( $val, $allowed_placements, true ) ) {
666 923 continue;
667 924 }
668 - $item_visibility[ $slug ] = $val;
925 + $nav_placement[ $slug ] = $val;
669 926 ++$count;
670 927 }
671 928 }
672 929
673 - // dockOrder — ordered list of item ids. Most are sanitize_key()-
674 - // clean dock slugs, but cross-rail tiles the user promoted carry a
675 - // rail-synthesis prefix (`desktop:<id>` / `dock:<id>`, built by
676 - // src/settings/item-placement.ts). sanitize_key() strips the colon,
677 - // which silently breaks the JS order match on reload and can collide
678 - // with an unrelated id — so allow the colon (and hyphen/underscore)
679 - // while still rejecting anything outside the JS id charset.
680 - $dock_order = array();
681 - 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 ) ) {
682 943 $seen = array();
683 - foreach ( $raw['dockOrder'] as $id ) {
944 + foreach ( $raw_order as $id ) {
684 945 if ( ! is_string( $id ) || '' === $id ) {
685 946 continue;
686 947 }
687 - $slug = (string) preg_replace( '/[^a-z0-9_:-]+/', '', strtolower( $id ) );
948 + $slug = sanitize_key( openstation_canonical_nav_id( $id ) );
688 949 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
689 950 continue;
690 951 }
691 952 $seen[ $slug ] = true;
692 - $dock_order[] = $slug;
693 - if ( count( $dock_order ) >= 256 ) {
953 + $nav_order[] = $slug;
954 + if ( count( $nav_order ) >= 256 ) {
694 955 break;
695 956 }
696 957 }
697 958 }
@@ -739,12 +1000,16 @@
739 1000
740 1001 return array(
741 1002 'wallpaper' => $wallpaper,
742 1003 'accent' => $accent,
1004 + 'customAccent' => $custom_accent,
743 1005 'dockSize' => $dock_size,
744 1006 'windowRadius' => $window_radius,
745 1007 'adminBarMode' => $admin_bar_mode,
746 1008 'desktopLayout' => $desktop_layout,
1009 + 'dockPlacement' => $dock_placement,
1010 + 'dockBehavior' => $dock_behavior,
1011 + 'sideDockBehavior' => $side_dock_behavior,
747 1012 'dockRailRenderer' => $dock_rail_renderer,
748 1013 'desktopTheme' => $desktop_theme,
749 1014 'appliedThemeRecommendations' => $applied_theme_recommendations,
750 1015 'unfocusEffect' => $unfocus_effect,
@@ -763,19 +1028,28 @@
763 1028 'heartbeatRate' => $heartbeat_rate,
764 1029 'nativePostsEnabled' => $native_posts_enabled,
765 1030 'nativePostsHiddenColumns' => $native_posts_hidden_columns,
766 1031 'nativePagesEnabled' => $native_pages_enabled,
1032 + 'nativePagesHiddenColumns' => $native_pages_hidden_columns,
767 1033 'nativeUsersEnabled' => $native_users_enabled,
768 1034 'nativePluginsEnabled' => $native_plugins_enabled,
769 1035 'nativeCommentsEnabled' => $native_comments_enabled,
1036 + 'stationHomeEnabled' => $station_home_enabled,
1037 + 'adminAssetCacheEnabled' => $admin_asset_cache_enabled,
1038 + 'windowPrewarmEnabled' => $window_prewarm_enabled,
770 1039 'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click,
1040 + 'confirmCloseAllWindows' => $confirm_close_all_windows,
771 1041 'mioEnabled' => $mio_enabled,
1042 + 'mioApiEnabled' => $mio_enabled,
1043 + 'mioShowOnWallpaper' => isset( $raw['mioShowOnWallpaper'] ) ? (bool) $raw['mioShowOnWallpaper'] : $defaults['mioShowOnWallpaper'],
772 1044 'mioStyle' => $mio_style,
773 1045 'showPostStatusRibbons' => $show_post_status_ribbons,
774 1046 'developerModeEnabled' => $developer_mode_enabled,
775 1047 'foldersSharingEnabled' => $folders_sharing_enabled,
776 - 'itemVisibility' => $item_visibility,
777 - 'dockOrder' => $dock_order,
1048 + 'navPlacement' => $nav_placement,
1049 + 'navOrder' => $nav_order,
1050 + 'mobileLayout' => $mobile_layout,
1051 + 'mobileTabs' => $mobile_tabs,
778 1052 'dockPromotedPositions' => $dock_promoted_positions,
779 1053 );
780 1054 }
781 1055
@@ -832,8 +1106,32 @@
832 1106
833 1107 /**
834 1108 * POST /desktop-mode/v1/os-settings
835 1109 *
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.
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 + *
836 1134 * @param WP_REST_Request $request The REST request.
837 1135 * @return WP_REST_Response The saved settings (after sanitization).
838 1136 */
839 1137 function openstation_rest_save_os_settings( WP_REST_Request $request ) {
@@ -838,9 +1136,32 @@
838 1136 */
839 1137 function openstation_rest_save_os_settings( WP_REST_Request $request ) {
840 1138 $user_id = get_current_user_id();
841 1139 $payload = $request->get_param( 'settings' );
842 - openstation_save_os_settings( $user_id, $payload );
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 + );
843 1164 return rest_ensure_response( openstation_get_os_settings( $user_id ) );
844 1165 }
845 1166
846 1167 /**