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 +454 -96 0.9.81.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,21 +8,29 @@
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
23 31 /** Valid window-radius IDs — mirrors the TS `WINDOW_RADII` constant. */
24 -const DESKTOP_MODE_OS_SETTINGS_WINDOW_RADII = array( 'sharp', 'default', 'round' );
32 +const OPENSTATION_OS_SETTINGS_WINDOW_RADII = array( 'sharp', 'default', 'round' );
25 33
26 34 /**
27 35 * Valid admin-bar mode IDs — mirrors the TS `ADMIN_BAR_MODES` constant.
28 36 *
@@ -29,14 +37,43 @@
29 37 * `static` keeps the WordPress admin bar pinned above the shell (the
30 38 * default), `dynamic` auto-hides it to a peek strip that reveals on
31 39 * hover or keyboard focus, and `hidden` removes it entirely.
32 40 */
33 -const DESKTOP_MODE_OS_SETTINGS_ADMIN_BAR_MODES = array( 'static', 'dynamic', 'hidden' );
41 +const OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES = array( 'static', 'dynamic', 'hidden' );
34 42
35 43 /** Valid desktop-layout IDs — mirrors the TS `DESKTOP_LAYOUTS` constant. */
36 -const DESKTOP_MODE_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified', 'spatial' );
44 +const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified' );
37 45
38 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 +/**
39 76 * Playable range for the window-reveal duration override, in ms.
40 77 * Mirrors `MIN_REVEAL_DURATION_MS` / `MAX_REVEAL_DURATION_MS` in
41 78 * `src/reveals/registry.ts`.
42 79 *
@@ -42,10 +79,10 @@
42 79 *
43 80 * `0` sits OUTSIDE this range on purpose: it is the "no override"
44 81 * sentinel, not a duration, and is handled before the clamp.
45 82 */
46 -const DESKTOP_MODE_OS_SETTINGS_REVEAL_DURATION_MIN = 80;
47 -const DESKTOP_MODE_OS_SETTINGS_REVEAL_DURATION_MAX = 4000;
83 +const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN = 80;
84 +const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX = 4000;
48 85
49 86 /**
50 87 * Returns a well-shaped default OS settings array.
51 88 *
@@ -53,18 +90,49 @@
53 90 * gets the same starting state in both environments.
54 91 *
55 92 * @return array
56 93 */
57 -function desktop_mode_default_os_settings() {
94 +function openstation_default_os_settings() {
58 95 return array(
59 - 'wallpaper' => 'dark',
60 - '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',
61 105 'dockSize' => 'default',
62 - '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',
63 114 // How the WordPress admin bar presents above the shell.
64 - // `static` is vanilla behavior and the shipped default.
65 - 'adminBarMode' => 'static',
66 - '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',
67 135 'dockRailRenderer' => 'default',
68 136 // Active desktop-theme slug, or `''` for the system default.
69 137 // Site-wide library (`includes/desktop-themes/`), per-user
70 138 // activation. Not validated against the installed list here —
@@ -126,9 +194,9 @@
126 194 'ai' => array(
127 195 'enabled' => false, // AI assistant is opt-in; enabled from OS Settings → Features once a provider is configured.
128 196 ),
129 197 // Per-user opt-IN for the native Posts window. When true,
130 - // clicking the Posts dock tile opens the `<wpd-table>`-driven
198 + // clicking the Posts dock tile opens the `<os-table>`-driven
131 199 // native window instead of the chromeless `edit.php` iframe.
132 200 // Default OFF — the native windows are opt-in
133 201 // Beta. Fresh installs land on the classic iframe; users turn
134 202 // this on in OS Settings → Features → Beta features to try it.
@@ -135,9 +203,9 @@
135 203 // Per-user override of the WordPress Heartbeat interval, in
136 204 // seconds. 60s matches Core's "idle" default; the allowed
137 205 // rates (15/30/45/60) all sit at or above Core's 15 s
138 206 // `minimalInterval` floor. See
139 - // `desktop_mode_apply_heartbeat_rate_setting` for the
207 + // `openstation_apply_heartbeat_rate_setting` for the
140 208 // `heartbeat_settings` filter that applies this.
141 209 'heartbeatRate' => 60,
142 210 'nativePostsEnabled' => false,
143 211 // Per-user list of column keys hidden in the native Posts
@@ -144,8 +212,13 @@
144 212 // window (e.g. array( 'author', 'tags' )). Empty array means
145 213 // every column is visible. The sticky 'title' column is always
146 214 // shown — the UI prevents toggling it.
147 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(),
148 221 // Per-user opt-IN for the native Pages window. Same posture as
149 222 // nativePostsEnabled — defaults OFF (Beta), users opt in to swap
150 223 // the classic `edit.php?post_type=page` iframe for the native UI.
151 224 'nativePagesEnabled' => false,
@@ -162,8 +235,17 @@
162 235 // Per-user opt-IN for the native Comments window. Defaults OFF
163 236 // (Beta); the server-side cap gate (`edit_posts`) means the
164 237 // toggle only matters for users who could see the Comments tile.
165 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,
166 248 // When true, left-clicking the empty wallpaper triggers the
167 249 // "Show desktop" toggle (macOS-style) and the matching entry is
168 250 // hidden from the wallpaper context menu. When false (default),
169 251 // the entry stays in the menu and left clicks on the wallpaper
@@ -168,8 +250,31 @@
168 250 // hidden from the wallpaper context menu. When false (default),
169 251 // the entry stays in the menu and left clicks on the wallpaper
170 252 // do nothing. Per-user.
171 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 + ),
172 277 // Diagonal corner ribbon on My WordPress tiles whose post
173 278 // status isn't `publish` (draft / pending / private /
174 279 // scheduled). On by default — surfaces unpublished work at
175 280 // a glance. Per-user.
@@ -175,11 +280,12 @@
175 280 // a glance. Per-user.
176 281 'showPostStatusRibbons' => true,
177 282 // Unlocks developer-facing surfaces meant for plugin
178 283 // authors: the Starter Widget appears in the add-widget
179 - // picker, and the OS Settings → Components tab runs its
180 - // intentional missing-import-warner demo. Off by default.
181 - // 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.
182 288 'developerModeEnabled' => false,
183 289 // Per-user opt-OUT for the folder-sharing feature. Defaults
184 290 // ON. When false:
185 291 // - The Share button, share-settings modal, "Leave shared
@@ -194,23 +300,30 @@
194 300 // disappears without any database changes. The site-wide
195 301 // "Delete folder sharing data" action in OS Settings →
196 302 // Features → Advanced is a separate destructive cleanup.
197 303 'foldersSharingEnabled' => true,
198 - // Per-item placement preferences. Map of item id (dock-item
199 - // slug or registered desktop-icon id) → one of:
200 - // 'both' — show on both dock and desktop.
201 - // 'dock' — show only on the dock; hide from desktop.
202 - // '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.
203 309 // 'hidden' — hide from every shell surface.
204 - // 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`.
205 312 // Sanitized as map<sanitize_key, enum>. Capped at 256 entries.
206 - 'itemVisibility' => array(),
207 - // Per-user dock ordering. Ordered list of item ids; ids not in
208 - // the list keep their server-supplied position appended after
209 - // the listed ones. Unknown ids are tolerated.
210 - 'dockOrder' => array(),
211 - // Persisted desktop position for every dock item the user has
212 - // 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`.
213 326 // Keyed by item id, value is `{ x: int, y: int }`. The JS
214 327 // synthesizer reads this when building a synthetic placement so
215 328 // the icon lands where the user last dragged it instead of
216 329 // resetting to (0, 0) on every reload. Capped at 256 entries.
@@ -226,20 +339,20 @@
226 339 *
227 340 * @param int $user_id The user ID.
228 341 * @return array
229 342 */
230 -function desktop_mode_get_os_settings( $user_id ) {
343 +function openstation_get_os_settings( $user_id ) {
231 344 $user_id = (int) $user_id;
232 345 if ( $user_id <= 0 ) {
233 - return desktop_mode_default_os_settings();
346 + return openstation_sanitize_os_settings( array() );
234 347 }
235 348
236 - $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 );
237 350 if ( ! is_array( $raw ) ) {
238 - return desktop_mode_default_os_settings();
351 + return openstation_sanitize_os_settings( array() );
239 352 }
240 353
241 - return desktop_mode_sanitize_os_settings( $raw );
354 + return openstation_sanitize_os_settings( $raw );
242 355 }
243 356
244 357 /**
245 358 * Saves sanitized OS settings for a user.
@@ -247,19 +360,87 @@
247 360 * @param int $user_id The user ID.
248 361 * @param mixed $settings Raw settings payload from the client.
249 362 * @return bool True on success, false otherwise.
250 363 */
251 -function desktop_mode_save_os_settings( $user_id, $settings ) {
364 +function openstation_save_os_settings( $user_id, $settings ) {
252 365 $user_id = (int) $user_id;
253 366 if ( $user_id <= 0 ) {
254 367 return false;
255 368 }
256 369
257 - $clean = desktop_mode_sanitize_os_settings( $settings );
258 - 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 );
259 372 }
260 373
261 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 +/**
262 443 * Sanitizes a raw OS settings payload.
263 444 *
264 445 * Unknown keys are ignored; known keys are coerced field-by-field so a
265 446 * partial save (e.g., only accent changed) merges cleanly with the
@@ -267,13 +448,13 @@
267 448 *
268 449 * @param mixed $raw Raw settings from the client or user meta.
269 450 * @return array Sanitized settings.
270 451 */
271 -function desktop_mode_sanitize_os_settings( $raw ) {
272 - $defaults = desktop_mode_default_os_settings();
452 +function openstation_sanitize_os_settings( $raw ) {
453 + $defaults = openstation_default_os_settings();
273 454
274 455 if ( ! is_array( $raw ) ) {
275 - return $defaults;
456 + $raw = array();
276 457 }
277 458
278 459 // Wallpaper — any non-empty string; registry membership is validated
279 460 // client-side at apply time.
@@ -285,31 +466,59 @@
285 466 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
286 467 ? sanitize_key( $raw['accent'] )
287 468 : $defaults['accent'];
288 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 +
289 480 // Dock size — must be one of the three known values.
290 - $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 )
291 482 ? (string) $raw['dockSize']
292 483 : $defaults['dockSize'];
293 484
294 485 // Window radius — must be one of the three known values.
295 - $window_radius = isset( $raw['windowRadius'] ) && in_array( $raw['windowRadius'], DESKTOP_MODE_OS_SETTINGS_WINDOW_RADII, true )
486 + $window_radius = isset( $raw['windowRadius'] ) && in_array( $raw['windowRadius'], OPENSTATION_OS_SETTINGS_WINDOW_RADII, true )
296 487 ? (string) $raw['windowRadius']
297 488 : $defaults['windowRadius'];
298 489
299 490 // Admin-bar mode — must be one of the three known values.
300 491 $admin_bar_mode = isset( $raw['adminBarMode'] )
301 - && in_array( $raw['adminBarMode'], DESKTOP_MODE_OS_SETTINGS_ADMIN_BAR_MODES, true )
492 + && in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true )
302 493 ? (string) $raw['adminBarMode']
303 494 : $defaults['adminBarMode'];
304 495
305 - // Desktop layout — must be one of the three known values
306 - // (`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`.
307 509 $desktop_layout = isset( $raw['desktopLayout'] )
308 - && in_array( $raw['desktopLayout'], DESKTOP_MODE_OS_SETTINGS_DESKTOP_LAYOUTS, true )
510 + && in_array( $raw['desktopLayout'], OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS, true )
309 511 ? (string) $raw['desktopLayout']
310 512 : $defaults['desktopLayout'];
311 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 +
312 521 // Dock rail renderer id — accept any sanitize_key()-clean
313 522 // string. JS-side registry resolves at use time and falls back
314 523 // to `'default'` when the picked renderer isn't registered.
315 524 $dock_rail_renderer = $defaults['dockRailRenderer'];
@@ -395,10 +604,10 @@
395 604 if ( isset( $raw['windowRevealDuration'] ) && is_numeric( $raw['windowRevealDuration'] ) ) {
396 605 $requested = (int) round( (float) $raw['windowRevealDuration'] );
397 606 if ( $requested > 0 ) {
398 607 $window_reveal_duration = max(
399 - DESKTOP_MODE_OS_SETTINGS_REVEAL_DURATION_MIN,
400 - min( DESKTOP_MODE_OS_SETTINGS_REVEAL_DURATION_MAX, $requested )
608 + OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN,
609 + min( OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX, $requested )
401 610 );
402 611 } else {
403 612 $window_reveal_duration = 0;
404 613 }
@@ -537,9 +746,9 @@
537 746 }
538 747 }
539 748
540 749 // Heartbeat rate — one of the four allowed values. The PHP
541 - // filter `desktop_mode_apply_heartbeat_rate_setting` reads
750 + // filter `openstation_apply_heartbeat_rate_setting` reads
542 751 // this and passes it through to `heartbeat_settings` so
543 752 // WordPress Core itself reduces the interval on the next page
544 753 // load. 5 s is intentionally excluded: Core's
545 754 // `minimalInterval` floor clamps anything below 15 back up to
@@ -576,8 +785,27 @@
576 785 // user meta indefinitely.
577 786 $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 );
578 787 }
579 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 +
580 808 $native_pages_enabled = isset( $raw['nativePagesEnabled'] )
581 809 ? (bool) $raw['nativePagesEnabled']
582 810 : $defaults['nativePagesEnabled'];
583 811
@@ -592,12 +820,37 @@
592 820 $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] )
593 821 ? (bool) $raw['nativeCommentsEnabled']
594 822 : $defaults['nativeCommentsEnabled'];
595 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 +
596 834 $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] )
597 835 ? (bool) $raw['showDesktopOnWallpaperClick']
598 836 : $defaults['showDesktopOnWallpaperClick'];
599 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 +
600 853 $show_post_status_ribbons = isset( $raw['showPostStatusRibbons'] )
601 854 ? (bool) $raw['showPostStatusRibbons']
602 855 : $defaults['showPostStatusRibbons'];
603 856
@@ -604,20 +857,59 @@
604 857 $developer_mode_enabled = isset( $raw['developerModeEnabled'] )
605 858 ? (bool) $raw['developerModeEnabled']
606 859 : $defaults['developerModeEnabled'];
607 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 +
608 888 $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] )
609 889 ? (bool) $raw['foldersSharingEnabled']
610 890 : $defaults['foldersSharingEnabled'];
611 891
612 - // itemVisibility — map<sanitize_key, enum>. Unknown ids are kept
892 + // navPlacement — map<sanitize_key, enum>. Unknown ids are kept
613 893 // (a deactivated plugin's setting should survive reactivation);
614 894 // invalid placement values are dropped.
615 - $item_visibility = array();
616 - if ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) {
617 - $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' );
618 910 $count = 0;
619 - foreach ( $raw['itemVisibility'] as $key => $val ) {
911 + foreach ( $raw_placement as $key => $val ) {
620 912 if ( $count >= 256 ) {
621 913 break;
622 914 }
623 915 if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) {
@@ -629,34 +921,38 @@
629 921 }
630 922 if ( ! in_array( $val, $allowed_placements, true ) ) {
631 923 continue;
632 924 }
633 - $item_visibility[ $slug ] = $val;
925 + $nav_placement[ $slug ] = $val;
634 926 ++$count;
635 927 }
636 928 }
637 929
638 - // dockOrder — ordered list of item ids. Most are sanitize_key()-
639 - // clean dock slugs, but cross-rail tiles the user promoted carry a
640 - // rail-synthesis prefix (`desktop:<id>` / `dock:<id>`, built by
641 - // src/settings/item-placement.ts). sanitize_key() strips the colon,
642 - // which silently breaks the JS order match on reload and can collide
643 - // with an unrelated id — so allow the colon (and hyphen/underscore)
644 - // while still rejecting anything outside the JS id charset.
645 - $dock_order = array();
646 - 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 ) ) {
647 943 $seen = array();
648 - foreach ( $raw['dockOrder'] as $id ) {
944 + foreach ( $raw_order as $id ) {
649 945 if ( ! is_string( $id ) || '' === $id ) {
650 946 continue;
651 947 }
652 - $slug = (string) preg_replace( '/[^a-z0-9_:-]+/', '', strtolower( $id ) );
948 + $slug = sanitize_key( openstation_canonical_nav_id( $id ) );
653 949 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
654 950 continue;
655 951 }
656 952 $seen[ $slug ] = true;
657 - $dock_order[] = $slug;
658 - if ( count( $dock_order ) >= 256 ) {
953 + $nav_order[] = $slug;
954 + if ( count( $nav_order ) >= 256 ) {
659 955 break;
660 956 }
661 957 }
662 958 }
@@ -704,12 +1000,16 @@
704 1000
705 1001 return array(
706 1002 'wallpaper' => $wallpaper,
707 1003 'accent' => $accent,
1004 + 'customAccent' => $custom_accent,
708 1005 'dockSize' => $dock_size,
709 1006 'windowRadius' => $window_radius,
710 1007 'adminBarMode' => $admin_bar_mode,
711 1008 'desktopLayout' => $desktop_layout,
1009 + 'dockPlacement' => $dock_placement,
1010 + 'dockBehavior' => $dock_behavior,
1011 + 'sideDockBehavior' => $side_dock_behavior,
712 1012 'dockRailRenderer' => $dock_rail_renderer,
713 1013 'desktopTheme' => $desktop_theme,
714 1014 'appliedThemeRecommendations' => $applied_theme_recommendations,
715 1015 'unfocusEffect' => $unfocus_effect,
@@ -728,17 +1028,28 @@
728 1028 'heartbeatRate' => $heartbeat_rate,
729 1029 'nativePostsEnabled' => $native_posts_enabled,
730 1030 'nativePostsHiddenColumns' => $native_posts_hidden_columns,
731 1031 'nativePagesEnabled' => $native_pages_enabled,
1032 + 'nativePagesHiddenColumns' => $native_pages_hidden_columns,
732 1033 'nativeUsersEnabled' => $native_users_enabled,
733 1034 'nativePluginsEnabled' => $native_plugins_enabled,
734 1035 'nativeCommentsEnabled' => $native_comments_enabled,
1036 + 'stationHomeEnabled' => $station_home_enabled,
1037 + 'adminAssetCacheEnabled' => $admin_asset_cache_enabled,
1038 + 'windowPrewarmEnabled' => $window_prewarm_enabled,
735 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,
736 1045 'showPostStatusRibbons' => $show_post_status_ribbons,
737 1046 'developerModeEnabled' => $developer_mode_enabled,
738 1047 'foldersSharingEnabled' => $folders_sharing_enabled,
739 - 'itemVisibility' => $item_visibility,
740 - 'dockOrder' => $dock_order,
1048 + 'navPlacement' => $nav_placement,
1049 + 'navOrder' => $nav_order,
1050 + 'mobileLayout' => $mobile_layout,
1051 + 'mobileTabs' => $mobile_tabs,
741 1052 'dockPromotedPositions' => $dock_promoted_positions,
742 1053 );
743 1054 }
744 1055
@@ -744,9 +1055,9 @@
744 1055
745 1056 /**
746 1057 * Registers the REST routes for OS settings.
747 1058 */
748 -function desktop_mode_register_os_settings_rest_routes() {
1059 +function openstation_register_os_settings_rest_routes() {
749 1060 register_rest_route(
750 1061 'desktop-mode/v1',
751 1062 '/os-settings',
752 1063 array(
@@ -751,15 +1062,15 @@
751 1062 '/os-settings',
752 1063 array(
753 1064 array(
754 1065 'methods' => WP_REST_Server::READABLE,
755 - 'callback' => 'desktop_mode_rest_get_os_settings',
756 - 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
1066 + 'callback' => 'openstation_rest_get_os_settings',
1067 + 'permission_callback' => 'openstation_rest_os_settings_permission',
757 1068 ),
758 1069 array(
759 1070 'methods' => WP_REST_Server::CREATABLE,
760 - 'callback' => 'desktop_mode_rest_save_os_settings',
761 - 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
1071 + 'callback' => 'openstation_rest_save_os_settings',
1072 + 'permission_callback' => 'openstation_rest_os_settings_permission',
762 1073 'args' => array(
763 1074 'settings' => array(
764 1075 'required' => true,
765 1076 'type' => 'object',
@@ -768,21 +1079,21 @@
768 1079 ),
769 1080 )
770 1081 );
771 1082 }
772 -add_action( 'rest_api_init', 'desktop_mode_register_os_settings_rest_routes' );
1083 +add_action( 'rest_api_init', 'openstation_register_os_settings_rest_routes' );
773 1084
774 1085 /**
775 1086 * Permission gate for OS settings REST routes.
776 1087 *
777 - * Requires the caller to be logged in *and* have desktop mode enabled —
778 - * 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
779 1090 * insufficient.
780 1091 *
781 1092 * @return true|WP_Error
782 1093 */
783 -function desktop_mode_rest_os_settings_permission() {
784 - return desktop_mode_rest_require_enabled();
1094 +function openstation_rest_os_settings_permission() {
1095 + return openstation_rest_require_enabled();
785 1096 }
786 1097
787 1098 /**
788 1099 * GET /desktop-mode/v1/os-settings
@@ -788,23 +1099,70 @@
788 1099 * GET /desktop-mode/v1/os-settings
789 1100 *
790 1101 * @return WP_REST_Response
791 1102 */
792 -function desktop_mode_rest_get_os_settings() {
793 - 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() ) );
794 1105 }
795 1106
796 1107 /**
797 1108 * POST /desktop-mode/v1/os-settings
798 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 + *
799 1134 * @param WP_REST_Request $request The REST request.
800 1135 * @return WP_REST_Response The saved settings (after sanitization).
801 1136 */
802 -function desktop_mode_rest_save_os_settings( WP_REST_Request $request ) {
1137 +function openstation_rest_save_os_settings( WP_REST_Request $request ) {
803 1138 $user_id = get_current_user_id();
804 1139 $payload = $request->get_param( 'settings' );
805 - desktop_mode_save_os_settings( $user_id, $payload );
806 - 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 ) );
807 1165 }
808 1166
809 1167 /**
810 1168 * Apply the per-user Heartbeat-rate preference to the
@@ -812,15 +1170,15 @@
812 1170 * once at page load. We set `interval` only; the allowed rates
813 1171 * (15/30/45/60 s) all sit at or above Core's 15 s
814 1172 * `minimalInterval` floor, so the floor never needs overriding.
815 1173 *
816 - * Only applies to users with Desktop Mode enabled — non-desktop
1174 + * Only applies to users with OpenStation enabled — non-desktop
817 1175 * sessions keep Core's defaults. Anonymous requests skip too.
818 1176 *
819 1177 * @param array $settings Filtered Heartbeat settings.
820 1178 * @return array
821 1179 */
822 -function desktop_mode_apply_heartbeat_rate_setting( $settings ) {
1180 +function openstation_apply_heartbeat_rate_setting( $settings ) {
823 1181 if ( ! is_array( $settings ) ) {
824 1182 $settings = array();
825 1183 }
826 1184 $user_id = get_current_user_id();
@@ -826,12 +1184,12 @@
826 1184 $user_id = get_current_user_id();
827 1185 if ( $user_id <= 0 ) {
828 1186 return $settings;
829 1187 }
830 - 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 ) ) {
831 1189 return $settings;
832 1190 }
833 - $os = desktop_mode_get_os_settings( $user_id );
1191 + $os = openstation_get_os_settings( $user_id );
834 1192 $rate = isset( $os['heartbeatRate'] ) ? (int) $os['heartbeatRate'] : 0;
835 1193 if ( ! in_array( $rate, array( 15, 30, 45, 60 ), true ) ) {
836 1194 return $settings;
837 1195 }
@@ -837,5 +1195,5 @@
837 1195 }
838 1196 $settings['interval'] = $rate;
839 1197 return $settings;
840 1198 }
841 -add_filter( 'heartbeat_settings', 'desktop_mode_apply_heartbeat_rate_setting' );
1199 +add_filter( 'heartbeat_settings', 'openstation_apply_heartbeat_rate_setting' );