PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
← All changes | includes/os-settings.php +389 -52 1.0.0 → 1.1.13 View file →
@@ -30,8 +30,11 @@
30 30
31 31 /** Valid window-radius IDs — mirrors the TS `WINDOW_RADII` constant. */
32 32 const OPENSTATION_OS_SETTINGS_WINDOW_RADII = array( 'sharp', 'default', 'round' );
33 33
34 +/** How a newly opened window lands — mirrors the TS `OPEN_WINDOWS_AS` constant. */
35 +const OPENSTATION_OS_SETTINGS_OPEN_WINDOWS_AS = array( 'default', 'maximized', 'focused' );
36 +
34 37 /**
35 38 * Valid admin-bar mode IDs — mirrors the TS `ADMIN_BAR_MODES` constant.
36 39 *
37 40 * `static` keeps the WordPress admin bar pinned above the shell (the
@@ -40,11 +43,40 @@
40 43 */
41 44 const OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES = array( 'static', 'dynamic', 'hidden' );
42 45
43 46 /** Valid desktop-layout IDs — mirrors the TS `DESKTOP_LAYOUTS` constant. */
44 -const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified', 'spatial' );
47 +const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified' );
45 48
46 49 /**
50 + * Valid dock-placement IDs — mirrors the TS `DOCK_PLACEMENTS` constant.
51 + *
52 + * Which edge the single dock sits on. Read by the layout dispatcher for
53 + * `unified`; `classic` derives its two rails from the layout itself and
54 + * ignores this.
55 + */
56 +const OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS = array( 'bottom', 'left', 'right' );
57 +
58 +/**
59 + * Valid dock-behavior IDs — mirrors the TS `DOCK_BEHAVIORS` constant.
60 + *
61 + * `static` keeps the dock always on screen (the default); `dynamic`
62 + * parks it off its edge behind a peek strip that reveals when the
63 + * pointer reaches that edge or something on it takes keyboard focus,
64 + * and releases the band it floats over from the work area.
65 + */
66 +const OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS = array( 'static', 'dynamic' );
67 +
68 +/**
69 + * `mobileLayout` — which experience the shell renders. `auto`
70 + * follows the viewport; the other two force it either way.
71 + * Mirrors `OsModePreference` in `src/mode/index.ts`.
72 + */
73 +const OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS = array( 'auto', 'desktop', 'mobile' );
74 +
75 +/** `mobileTabs` — at most this many ids pinned to the phone tab bar. */
76 +const OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX = 3;
77 +
78 +/**
47 79 * Playable range for the window-reveal duration override, in ms.
48 80 * Mirrors `MIN_REVEAL_DURATION_MS` / `MAX_REVEAL_DURATION_MS` in
49 81 * `src/reveals/registry.ts`.
50 82 *
@@ -64,15 +96,47 @@
64 96 */
65 97 function openstation_default_os_settings() {
66 98 return array(
67 99 'wallpaper' => 'galaxy',
100 + // Pulse, the brand's signature. Mirrors `DEFAULTS` in
101 + // `src/settings/constants.ts`.
68 102 'accent' => 'pulse',
103 + // Only read when `accent` is `custom`. Seeded with Pulse so
104 + // picking Custom before touching the wheel is a no-op rather
105 + // than a jump to black. Mirrors `DEFAULTS` in
106 + // `src/settings/constants.ts`.
107 + 'customAccent' => '#f252fc',
69 108 'dockSize' => 'default',
70 - 'windowRadius' => 'default',
109 + // `round` (16px), not the preset id literally named `default`.
110 + // Preset ids are stored values and cannot be renamed, so the
111 + // option labelled "Default" in the picker is no longer the
112 + // shipped default. Must stay in step with `DEFAULTS` in
113 + // `src/settings/constants.ts` — PHP seeds the first load and JS
114 + // owns every paint after it, so a mismatch shows up as the
115 + // corners changing shape a moment after the shell boots.
116 + 'windowRadius' => 'round',
117 + 'openWindowsAs' => 'default',
71 118 // How the WordPress admin bar presents above the shell.
72 - // `static` is vanilla behavior and the shipped default.
73 - 'adminBarMode' => 'static',
74 - 'desktopLayout' => 'classic',
119 + // `hidden` ships as the default so a fresh desktop has ONE
120 + // navigation surface: everything the user can open lives on the
121 + // dock, and the dock's "Exit OpenStation" tile is the way back
122 + // to classic admin. `static` (vanilla behavior) and `dynamic`
123 + // are one pick away in OpenStation Preferences → Appearance.
124 + 'adminBarMode' => 'hidden',
125 + // Always on screen. `dynamic` (auto-hide behind a peek strip)
126 + // is one pick away in OpenStation Preferences → Appearance.
127 + 'dockBehavior' => 'static',
128 + // The Split layout's sidebar answers for itself: a folded
129 + // sidebar over a static bottom dock is a valid desk.
130 + 'sideDockBehavior' => 'static',
131 + // One dock holding every menu, with the system tiles grouped
132 + // behind a hairline. `classic` (side bar for core menus + bottom
133 + // dock for plugins) is the other option; it is no longer what a
134 + // first-run desktop looks like.
135 + 'desktopLayout' => 'unified',
136 + // Which edge the single dock sits on. Ignored by `classic`,
137 + // which derives both of its rails from the layout.
138 + 'dockPlacement' => 'bottom',
75 139 'dockRailRenderer' => 'default',
76 140 // Active desktop-theme slug, or `''` for the system default.
77 141 // Site-wide library (`includes/desktop-themes/`), per-user
78 142 // activation. Not validated against the installed list here —
@@ -152,8 +216,13 @@
152 216 // window (e.g. array( 'author', 'tags' )). Empty array means
153 217 // every column is visible. The sticky 'title' column is always
154 218 // shown — the UI prevents toggling it.
155 219 'nativePostsHiddenColumns' => array(),
220 + // Per-user list of column keys hidden in the native Pages
221 + // window (e.g. array( 'author', 'parent' )). Empty array means
222 + // every column is visible. The sticky 'title' column is always
223 + // shown — the UI prevents toggling it.
224 + 'nativePagesHiddenColumns' => array(),
156 225 // Per-user opt-IN for the native Pages window. Same posture as
157 226 // nativePostsEnabled — defaults OFF (Beta), users opt in to swap
158 227 // the classic `edit.php?post_type=page` iframe for the native UI.
159 228 'nativePagesEnabled' => false,
@@ -170,8 +239,17 @@
170 239 // Per-user opt-IN for the native Comments window. Defaults OFF
171 240 // (Beta); the server-side cap gate (`edit_posts`) means the
172 241 // toggle only matters for users who could see the Comments tile.
173 242 'nativeCommentsEnabled' => false,
243 + // Per-user opt-IN for Station Home, the native Dashboard
244 + // window. Defaults OFF: the ordinary `index.php` Dashboard
245 + // (including any custom dashboard a plugin builds there) opens
246 + // as a chromeless iframe until the user opts in via OS
247 + // Settings → Features → Beta features.
248 + 'stationHomeEnabled' => false,
249 + // Performance enhancements are enabled unless explicitly disabled.
250 + 'adminAssetCacheEnabled' => true,
251 + 'windowPrewarmEnabled' => true,
174 252 // When true, left-clicking the empty wallpaper triggers the
175 253 // "Show desktop" toggle (macOS-style) and the matching entry is
176 254 // hidden from the wallpaper context menu. When false (default),
177 255 // the entry stays in the menu and left clicks on the wallpaper
@@ -176,13 +254,20 @@
176 254 // hidden from the wallpaper context menu. When false (default),
177 255 // the entry stays in the menu and left clicks on the wallpaper
178 256 // do nothing. Per-user.
179 257 'showDesktopOnWallpaperClick' => false,
258 + // Whether the close-all-windows shortcut (Alt+Cmd/Ctrl+W) asks
259 + // before it closes. The dialog's "Don't ask again" checkbox is
260 + // what writes false; OpenStation Preferences -> Windows is what
261 + // turns it back on. Per-user.
262 + 'confirmCloseAllWindows' => true,
180 263 // Mio — a soft-body companion that floats over
181 264 // the wallpaper, settles onto nearby windows, and watches the
182 265 // pointer. Off by default; toggled from the wallpaper context
183 266 // menu. Per-user. See `docs/mio.md`.
184 267 'mioEnabled' => false,
268 + 'mioApiEnabled' => false,
269 + 'mioShowOnWallpaper' => true,
185 270 // The user's own Mio, as built in "Make it yours": partial
186 271 // appearance + silhouette overrides, both empty until they
187 272 // touch a control. Stored per user rather than per browser
188 273 // because it is a preference about the person — ten minutes
@@ -199,11 +284,12 @@
199 284 // a glance. Per-user.
200 285 'showPostStatusRibbons' => true,
201 286 // Unlocks developer-facing surfaces meant for plugin
202 287 // 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.
288 + // picker, the OS Settings → Components tab runs its
289 + // intentional missing-import-warner demo, and the Code Blue
290 + // error-log reader registers (icon, window, REST routes).
291 + // Off by default. Per-user.
206 292 'developerModeEnabled' => false,
207 293 // Per-user opt-OUT for the folder-sharing feature. Defaults
208 294 // ON. When false:
209 295 // - The Share button, share-settings modal, "Leave shared
@@ -218,23 +304,30 @@
218 304 // disappears without any database changes. The site-wide
219 305 // "Delete folder sharing data" action in OS Settings →
220 306 // Features → Advanced is a separate destructive cleanup.
221 307 '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.
308 + // Per-item navigation placement. Map of item id → one of:
309 + // 'both' — show on a rail and on the desktop.
310 + // 'rail' — show only on a rail: the dock, or the sidebar
311 + // for a Core admin menu in the split layout.
312 + // 'desktop' — show only on the wallpaper.
227 313 // 'hidden' — hide from every shell surface.
228 - // Missing keys mean "no override" — items use their native rail.
314 + // Missing keys mean "no override" — the item takes the default
315 + // for its kind, which lives in `src/nav/defaults.ts`.
229 316 // 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`.
317 + 'navPlacement' => array(),
318 + // Per-user ordering, flat across every dock/sidebar zone. Ids
319 + // not in the list keep their registration order and render
320 + // after the listed ones. Unknown ids are tolerated.
321 + 'navOrder' => array(),
322 + // Which experience the shell renders: 'auto' follows the
323 + // viewport, 'desktop' / 'mobile' force it. See `includes/mobile.php`.
324 + 'mobileLayout' => 'auto',
325 + // Ids pinned to the phone tab bar, at most three. Empty means
326 + // the server default (`openstation_mobile_tab_bar`).
327 + 'mobileTabs' => array(),
328 + // Persisted desktop position for every item the user has
329 + // promoted to the wallpaper via `navPlacement[id]=desktop|both`.
237 330 // Keyed by item id, value is `{ x: int, y: int }`. The JS
238 331 // synthesizer reads this when building a synthetic placement so
239 332 // the icon lands where the user last dragged it instead of
240 333 // resetting to (0, 0) on every reload. Capped at 256 entries.
@@ -253,14 +346,14 @@
253 346 */
254 347 function openstation_get_os_settings( $user_id ) {
255 348 $user_id = (int) $user_id;
256 349 if ( $user_id <= 0 ) {
257 - return openstation_default_os_settings();
350 + return openstation_sanitize_os_settings( array() );
258 351 }
259 352
260 353 $raw = get_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true );
261 354 if ( ! is_array( $raw ) ) {
262 - return openstation_default_os_settings();
355 + return openstation_sanitize_os_settings( array() );
263 356 }
264 357
265 358 return openstation_sanitize_os_settings( $raw );
266 359 }
@@ -282,8 +375,76 @@
282 375 return false !== update_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, $clean );
283 376 }
284 377
285 378 /**
379 + * Strip the rail-synthesis prefix an id could carry before the
380 + * navigation model.
381 + *
382 + * `dock:<id>` / `desktop:<id>` used to mean "this tile is a copy of an
383 + * item whose real home is the other rail". Nothing synthesizes copies
384 + * any more — an item is one item wherever it is painted — so the
385 + * prefix is noise, and left in place it would key a preference to an
386 + * id nothing registers.
387 + *
388 + * @param string $id Possibly-prefixed id.
389 + * @return string Canonical id.
390 + */
391 +function openstation_canonical_nav_id( $id ) {
392 + $id = (string) $id;
393 + if ( 0 === strpos( $id, 'dock:' ) ) {
394 + return substr( $id, 5 );
395 + }
396 + if ( 0 === strpos( $id, 'desktop:' ) ) {
397 + return substr( $id, 8 );
398 + }
399 + return $id;
400 +}
401 +
402 +/**
403 + * Carry a pre-navigation `itemVisibility` map into `navPlacement`.
404 + *
405 + * The only value that moves is `'dock'` → `'rail'`: the stored name
406 + * is now the REGION rather than a rail, so a Core admin menu the user
407 + * kept on a rail follows the layout into the sidebar instead of
408 + * needing a second migration the first time they switch.
409 + *
410 + * Runs on read (see {@see openstation_sanitize_os_settings()}) rather
411 + * than as a numbered migration, because OS settings are per-user meta
412 + * and a site with many users would pay for a sweep that the next save
413 + * performs for free.
414 + *
415 + * @param array $visibility Legacy map of item id → placement.
416 + * @return array Map of canonical item id → nav placement.
417 + */
418 +function openstation_migrate_item_visibility( $visibility ) {
419 + $map = array(
420 + 'dock' => 'rail',
421 + 'desktop' => 'desktop',
422 + 'both' => 'both',
423 + 'hidden' => 'hidden',
424 + );
425 +
426 + $out = array();
427 + foreach ( (array) $visibility as $key => $val ) {
428 + if ( ! is_string( $key ) || ! is_string( $val ) || ! isset( $map[ $val ] ) ) {
429 + continue;
430 + }
431 + $id = openstation_canonical_nav_id( $key );
432 + if ( '' === $id ) {
433 + continue;
434 + }
435 + // A prefixed and an unprefixed key can collapse onto the same
436 + // id. The unprefixed one is the item's own preference rather
437 + // than a synthesized copy's, so it wins whichever order they
438 + // arrive in.
439 + if ( $id === $key || ! isset( $out[ $id ] ) ) {
440 + $out[ $id ] = $map[ $val ];
441 + }
442 + }
443 + return $out;
444 +}
445 +
446 +/**
286 447 * Sanitizes a raw OS settings payload.
287 448 *
288 449 * Unknown keys are ignored; known keys are coerced field-by-field so a
289 450 * partial save (e.g., only accent changed) merges cleanly with the
@@ -295,16 +456,22 @@
295 456 function openstation_sanitize_os_settings( $raw ) {
296 457 $defaults = openstation_default_os_settings();
297 458
298 459 if ( ! is_array( $raw ) ) {
299 - return $defaults;
460 + $raw = array();
300 461 }
301 462
302 463 // Wallpaper — any non-empty string; registry membership is validated
303 464 // client-side at apply time.
304 - $wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] ) && '' !== $raw['wallpaper']
305 - ? sanitize_key( $raw['wallpaper'] )
306 - : $defaults['wallpaper'];
465 + // Key-clean per `/` segment: a desktop theme's wallpapers are
466 + // registered as `desktop-theme/<theme>/<id>`, and a flat
467 + // `sanitize_key()` would fold that into an id nothing answers to.
468 + $wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] )
469 + ? implode( '/', array_filter( array_map( 'sanitize_key', explode( '/', $raw['wallpaper'] ) ) ) )
470 + : '';
471 + if ( '' === $wallpaper ) {
472 + $wallpaper = $defaults['wallpaper'];
473 + }
307 474
308 475 // Accent — non-empty string; swatch validity is enforced in the picker.
309 476 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
310 477 ? sanitize_key( $raw['accent'] )
@@ -309,8 +476,18 @@
309 476 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
310 477 ? sanitize_key( $raw['accent'] )
311 478 : $defaults['accent'];
312 479
480 + // The colour behind the Custom swatch. A full `#rrggbb` triplet and
481 + // nothing else: `sanitize_hex_color()` would also pass `#abc`, which
482 + // the client-side parser rejects, and a value that survives the save
483 + // only to be dropped on load is worse than one refused here.
484 + $custom_accent = isset( $raw['customAccent'] )
485 + && is_string( $raw['customAccent'] )
486 + && preg_match( '/^#[0-9a-fA-F]{6}$/', $raw['customAccent'] )
487 + ? strtolower( $raw['customAccent'] )
488 + : $defaults['customAccent'];
489 +
313 490 // Dock size — must be one of the three known values.
314 491 $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], OPENSTATION_OS_SETTINGS_DOCK_SIZES, true )
315 492 ? (string) $raw['dockSize']
316 493 : $defaults['dockSize'];
@@ -319,8 +496,13 @@
319 496 $window_radius = isset( $raw['windowRadius'] ) && in_array( $raw['windowRadius'], OPENSTATION_OS_SETTINGS_WINDOW_RADII, true )
320 497 ? (string) $raw['windowRadius']
321 498 : $defaults['windowRadius'];
322 499
500 + // How a newly opened window lands — one of the three known values.
501 + $open_windows_as = isset( $raw['openWindowsAs'] ) && in_array( $raw['openWindowsAs'], OPENSTATION_OS_SETTINGS_OPEN_WINDOWS_AS, true )
502 + ? (string) $raw['openWindowsAs']
503 + : $defaults['openWindowsAs'];
504 +
323 505 // Admin-bar mode — must be one of the three known values.
324 506 $admin_bar_mode = isset( $raw['adminBarMode'] )
325 507 && in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true )
326 508 ? (string) $raw['adminBarMode']
@@ -325,15 +507,33 @@
325 507 && in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true )
326 508 ? (string) $raw['adminBarMode']
327 509 : $defaults['adminBarMode'];
328 510
329 - // Desktop layout — must be one of the three known values
330 - // (`classic`, `unified`, `spatial`). Default `classic`.
511 + // Dock behavior — must be one of the two known values. One answer
512 + // per rail: the dock, and the Split layout's sidebar.
513 + $dock_behavior = isset( $raw['dockBehavior'] )
514 + && in_array( $raw['dockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true )
515 + ? (string) $raw['dockBehavior']
516 + : $defaults['dockBehavior'];
517 + $side_dock_behavior = isset( $raw['sideDockBehavior'] )
518 + && in_array( $raw['sideDockBehavior'], OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS, true )
519 + ? (string) $raw['sideDockBehavior']
520 + : $defaults['sideDockBehavior'];
521 +
522 + // Desktop layout — must be one of the known values (`classic`,
523 + // `unified`). Default `unified`.
331 524 $desktop_layout = isset( $raw['desktopLayout'] )
332 525 && in_array( $raw['desktopLayout'], OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS, true )
333 526 ? (string) $raw['desktopLayout']
334 527 : $defaults['desktopLayout'];
335 528
529 + // Dock placement — which edge the single dock sits on. Must be one
530 + // of the three known values (`bottom`, `left`, `right`).
531 + $dock_placement = isset( $raw['dockPlacement'] )
532 + && in_array( $raw['dockPlacement'], OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS, true )
533 + ? (string) $raw['dockPlacement']
534 + : $defaults['dockPlacement'];
535 +
336 536 // Dock rail renderer id — accept any sanitize_key()-clean
337 537 // string. JS-side registry resolves at use time and falls back
338 538 // to `'default'` when the picked renderer isn't registered.
339 539 $dock_rail_renderer = $defaults['dockRailRenderer'];
@@ -600,8 +800,27 @@
600 800 // user meta indefinitely.
601 801 $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 );
602 802 }
603 803
804 + $native_pages_hidden_columns = $defaults['nativePagesHiddenColumns'];
805 + if ( isset( $raw['nativePagesHiddenColumns'] ) && is_array( $raw['nativePagesHiddenColumns'] ) ) {
806 + $native_pages_hidden_columns = array();
807 + foreach ( $raw['nativePagesHiddenColumns'] as $col ) {
808 + if ( ! is_string( $col ) || '' === $col ) {
809 + continue;
810 + }
811 + $slug = sanitize_key( $col );
812 + if ( '' === $slug ) {
813 + continue;
814 + }
815 + $native_pages_hidden_columns[] = $slug;
816 + }
817 + // Cap to a sane upper bound — far more than any plausible
818 + // column count, but blocks a malicious payload from bloating
819 + // user meta indefinitely.
820 + $native_pages_hidden_columns = array_slice( array_values( array_unique( $native_pages_hidden_columns ) ), 0, 32 );
821 + }
822 +
604 823 $native_pages_enabled = isset( $raw['nativePagesEnabled'] )
605 824 ? (bool) $raw['nativePagesEnabled']
606 825 : $defaults['nativePagesEnabled'];
607 826
@@ -616,15 +835,29 @@
616 835 $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] )
617 836 ? (bool) $raw['nativeCommentsEnabled']
618 837 : $defaults['nativeCommentsEnabled'];
619 838
839 + $station_home_enabled = isset( $raw['stationHomeEnabled'] )
840 + ? (bool) $raw['stationHomeEnabled']
841 + : $defaults['stationHomeEnabled'];
842 +
843 + // Site-wide performance controls supersede the legacy per-user values.
844 + // Keep the snapshot keys so existing window consumers can read them.
845 + $extended_options = openstation_get_extended_options();
846 + $admin_asset_cache_enabled = $extended_options['admin_asset_cache'];
847 + $window_prewarm_enabled = $extended_options['window_prewarm'];
848 +
620 849 $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] )
621 850 ? (bool) $raw['showDesktopOnWallpaperClick']
622 851 : $defaults['showDesktopOnWallpaperClick'];
623 852
853 + $confirm_close_all_windows = isset( $raw['confirmCloseAllWindows'] )
854 + ? (bool) $raw['confirmCloseAllWindows']
855 + : $defaults['confirmCloseAllWindows'];
856 +
624 857 $mio_enabled = isset( $raw['mioEnabled'] )
625 858 ? (bool) $raw['mioEnabled']
626 - : $defaults['mioEnabled'];
859 + : ( isset( $raw['mioApiEnabled'] ) ? (bool) $raw['mioApiEnabled'] : $defaults['mioEnabled'] );
627 860
628 861 // A missing key means "no look saved yet", which sanitizes to the
629 862 // same pair of empty arrays the defaults carry — so this needs no
630 863 // isset() branch of its own.
@@ -639,20 +872,59 @@
639 872 $developer_mode_enabled = isset( $raw['developerModeEnabled'] )
640 873 ? (bool) $raw['developerModeEnabled']
641 874 : $defaults['developerModeEnabled'];
642 875
876 + // mobileLayout — the phone/desktop override.
877 + $mobile_layout = isset( $raw['mobileLayout'] )
878 + && in_array( $raw['mobileLayout'], OPENSTATION_OS_SETTINGS_MOBILE_LAYOUTS, true )
879 + ? (string) $raw['mobileLayout']
880 + : $defaults['mobileLayout'];
881 +
882 + // mobileTabs — ordered nav ids pinned to the phone tab bar. Same
883 + // id grammar as navOrder, capped at the tab bar's slot count.
884 + $mobile_tabs = array();
885 + if ( isset( $raw['mobileTabs'] ) && is_array( $raw['mobileTabs'] ) ) {
886 + $seen_tabs = array();
887 + foreach ( $raw['mobileTabs'] as $id ) {
888 + if ( ! is_string( $id ) || '' === $id ) {
889 + continue;
890 + }
891 + $slug = sanitize_key( openstation_canonical_nav_id( $id ) );
892 + if ( '' === $slug || isset( $seen_tabs[ $slug ] ) ) {
893 + continue;
894 + }
895 + $seen_tabs[ $slug ] = true;
896 + $mobile_tabs[] = $slug;
897 + if ( count( $mobile_tabs ) >= OPENSTATION_OS_SETTINGS_MOBILE_TABS_MAX ) {
898 + break;
899 + }
900 + }
901 + }
902 +
643 903 $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] )
644 904 ? (bool) $raw['foldersSharingEnabled']
645 905 : $defaults['foldersSharingEnabled'];
646 906
647 - // itemVisibility — map<sanitize_key, enum>. Unknown ids are kept
907 + // navPlacement — map<sanitize_key, enum>. Unknown ids are kept
648 908 // (a deactivated plugin's setting should survive reactivation);
649 909 // 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' );
910 + //
911 + // Reads the pre-navigation `itemVisibility` map when this user has
912 + // no `navPlacement` yet, so an existing arrangement carries over on
913 + // first load and is written back on the next save. See
914 + // `openstation_migrate_item_visibility()`.
915 + $raw_placement = array();
916 + if ( isset( $raw['navPlacement'] ) && is_array( $raw['navPlacement'] ) ) {
917 + $raw_placement = $raw['navPlacement'];
918 + } elseif ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) {
919 + $raw_placement = openstation_migrate_item_visibility( $raw['itemVisibility'] );
920 + }
921 +
922 + $nav_placement = array();
923 + if ( ! empty( $raw_placement ) ) {
924 + $allowed_placements = array( 'both', 'rail', 'desktop', 'hidden' );
653 925 $count = 0;
654 - foreach ( $raw['itemVisibility'] as $key => $val ) {
926 + foreach ( $raw_placement as $key => $val ) {
655 927 if ( $count >= 256 ) {
656 928 break;
657 929 }
658 930 if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) {
@@ -664,34 +936,38 @@
664 936 }
665 937 if ( ! in_array( $val, $allowed_placements, true ) ) {
666 938 continue;
667 939 }
668 - $item_visibility[ $slug ] = $val;
940 + $nav_placement[ $slug ] = $val;
669 941 ++$count;
670 942 }
671 943 }
672 944
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'] ) ) {
945 + // navOrder — ordered list of item ids, flat across every zone.
946 + // Reads the pre-navigation `dockOrder` when absent, stripping the
947 + // rail-synthesis prefixes (`dock:` / `desktop:`) that model no
948 + // longer has.
949 + $raw_order = array();
950 + if ( isset( $raw['navOrder'] ) && is_array( $raw['navOrder'] ) ) {
951 + $raw_order = $raw['navOrder'];
952 + } elseif ( isset( $raw['dockOrder'] ) && is_array( $raw['dockOrder'] ) ) {
953 + $raw_order = $raw['dockOrder'];
954 + }
955 +
956 + $nav_order = array();
957 + if ( ! empty( $raw_order ) ) {
682 958 $seen = array();
683 - foreach ( $raw['dockOrder'] as $id ) {
959 + foreach ( $raw_order as $id ) {
684 960 if ( ! is_string( $id ) || '' === $id ) {
685 961 continue;
686 962 }
687 - $slug = (string) preg_replace( '/[^a-z0-9_:-]+/', '', strtolower( $id ) );
963 + $slug = sanitize_key( openstation_canonical_nav_id( $id ) );
688 964 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
689 965 continue;
690 966 }
691 967 $seen[ $slug ] = true;
692 - $dock_order[] = $slug;
693 - if ( count( $dock_order ) >= 256 ) {
968 + $nav_order[] = $slug;
969 + if ( count( $nav_order ) >= 256 ) {
694 970 break;
695 971 }
696 972 }
697 973 }
@@ -739,12 +1015,17 @@
739 1015
740 1016 return array(
741 1017 'wallpaper' => $wallpaper,
742 1018 'accent' => $accent,
1019 + 'customAccent' => $custom_accent,
743 1020 'dockSize' => $dock_size,
744 1021 'windowRadius' => $window_radius,
1022 + 'openWindowsAs' => $open_windows_as,
745 1023 'adminBarMode' => $admin_bar_mode,
746 1024 'desktopLayout' => $desktop_layout,
1025 + 'dockPlacement' => $dock_placement,
1026 + 'dockBehavior' => $dock_behavior,
1027 + 'sideDockBehavior' => $side_dock_behavior,
747 1028 'dockRailRenderer' => $dock_rail_renderer,
748 1029 'desktopTheme' => $desktop_theme,
749 1030 'appliedThemeRecommendations' => $applied_theme_recommendations,
750 1031 'unfocusEffect' => $unfocus_effect,
@@ -763,19 +1044,28 @@
763 1044 'heartbeatRate' => $heartbeat_rate,
764 1045 'nativePostsEnabled' => $native_posts_enabled,
765 1046 'nativePostsHiddenColumns' => $native_posts_hidden_columns,
766 1047 'nativePagesEnabled' => $native_pages_enabled,
1048 + 'nativePagesHiddenColumns' => $native_pages_hidden_columns,
767 1049 'nativeUsersEnabled' => $native_users_enabled,
768 1050 'nativePluginsEnabled' => $native_plugins_enabled,
769 1051 'nativeCommentsEnabled' => $native_comments_enabled,
1052 + 'stationHomeEnabled' => $station_home_enabled,
1053 + 'adminAssetCacheEnabled' => $admin_asset_cache_enabled,
1054 + 'windowPrewarmEnabled' => $window_prewarm_enabled,
770 1055 'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click,
1056 + 'confirmCloseAllWindows' => $confirm_close_all_windows,
771 1057 'mioEnabled' => $mio_enabled,
1058 + 'mioApiEnabled' => $mio_enabled,
1059 + 'mioShowOnWallpaper' => isset( $raw['mioShowOnWallpaper'] ) ? (bool) $raw['mioShowOnWallpaper'] : $defaults['mioShowOnWallpaper'],
772 1060 'mioStyle' => $mio_style,
773 1061 'showPostStatusRibbons' => $show_post_status_ribbons,
774 1062 'developerModeEnabled' => $developer_mode_enabled,
775 1063 'foldersSharingEnabled' => $folders_sharing_enabled,
776 - 'itemVisibility' => $item_visibility,
777 - 'dockOrder' => $dock_order,
1064 + 'navPlacement' => $nav_placement,
1065 + 'navOrder' => $nav_order,
1066 + 'mobileLayout' => $mobile_layout,
1067 + 'mobileTabs' => $mobile_tabs,
778 1068 'dockPromotedPositions' => $dock_promoted_positions,
779 1069 );
780 1070 }
781 1071
@@ -832,8 +1122,32 @@
832 1122
833 1123 /**
834 1124 * POST /desktop-mode/v1/os-settings
835 1125 *
1126 + * Accepts a PARTIAL payload: keys the request omits keep the value
1127 + * already stored for the user, rather than resetting to the shipped
1128 + * default. The client sends only the fields that changed since its
1129 + * last confirmed save, which is what stops two open sessions from
1130 + * overwriting each other — a session that never touched the
1131 + * wallpaper cannot express an opinion about it, so a stale snapshot
1132 + * can no longer undo another session's unrelated change.
1133 + *
1134 + * A full payload still behaves exactly as before: every key is
1135 + * present, so every key wins.
1136 + *
1137 + * The merge lives here rather than in {@see openstation_save_os_settings()}
1138 + * on purpose. That function's contract is REPLACE, and migrations
1139 + * depend on it: migration 1 in `includes/migrations.php` `unset()`s
1140 + * keys and re-saves precisely so the sanitizer backfills the new
1141 + * defaults. Give the saver merge semantics and that migration
1142 + * silently becomes a no-op.
1143 + *
1144 + * Merging is shallow, one level deep. For the map-shaped fields
1145 + * (`wallpaperSettings`, `navPlacement`, `navOrder`,
1146 + * `dockPromotedPositions`) a request that sends the key replaces the
1147 + * whole map — deep-merging them would leave no way to delete an
1148 + * entry.
1149 + *
836 1150 * @param WP_REST_Request $request The REST request.
837 1151 * @return WP_REST_Response The saved settings (after sanitization).
838 1152 */
839 1153 function openstation_rest_save_os_settings( WP_REST_Request $request ) {
@@ -838,9 +1152,32 @@
838 1152 */
839 1153 function openstation_rest_save_os_settings( WP_REST_Request $request ) {
840 1154 $user_id = get_current_user_id();
841 1155 $payload = $request->get_param( 'settings' );
842 - openstation_save_os_settings( $user_id, $payload );
1156 +
1157 + // A payload that isn't an object says nothing about any field, so
1158 + // it changes nothing. The route declares `'settings' => object`
1159 + // and WP's schema validation rejects a scalar before the callback
1160 + // runs, so this is unreachable over real REST traffic — but the
1161 + // sanitizer resolves a non-array to the full defaults, which
1162 + // means the one way to reach this function with a bad payload
1163 + // used to be the one way to wipe a user's settings. Returning
1164 + // early costs nothing and keeps "don't destroy what wasn't sent"
1165 + // true of every path into this handler, not just the ones the
1166 + // schema happens to guard.
1167 + if ( ! is_array( $payload ) ) {
1168 + return rest_ensure_response( openstation_get_os_settings( $user_id ) );
1169 + }
1170 +
1171 + // Normalize an alias-only patch before merging it with the saved master value.
1172 + if ( ! array_key_exists( 'mioEnabled', $payload ) && array_key_exists( 'mioApiEnabled', $payload ) ) {
1173 + $payload['mioEnabled'] = $payload['mioApiEnabled'];
1174 + }
1175 +
1176 + openstation_save_os_settings(
1177 + $user_id,
1178 + array_merge( openstation_get_os_settings( $user_id ), $payload )
1179 + );
843 1180 return rest_ensure_response( openstation_get_os_settings( $user_id ) );
844 1181 }
845 1182
846 1183 /**