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 +765 -195 0.9.0 → 1.1.13 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — OS Settings Persistence.
3 + * OpenStation — OS Settings Persistence.
4 4 *
5 5 * Persists each user's OS Settings preferences (wallpaper, accent color,
6 6 * dock size, custom gradient/image, HD-only toggle, and AI integration
7 7 * settings) to user meta so they survive across browsers, devices, and
@@ -8,46 +8,84 @@
8 8 * private/incognito sessions. The JS layer writes to localStorage on
9 9 * every change for instant read-back, then asynchronously syncs to this
10 10 * endpoint so user meta is the durable source of truth.
11 11 *
12 - * @package WPDesktopMode
12 + * @package OpenStation
13 13 */
14 14
15 15 defined( 'ABSPATH' ) || exit;
16 16
17 -/** User meta key for OS Settings. */
18 -const DESKTOP_MODE_OS_SETTINGS_META_KEY = 'desktop_mode_os_settings';
17 +/**
18 + * User meta key for OS Settings.
19 + *
20 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
21 + * persisted or externally-visible identifier, so renaming it would
22 + * orphan data already written by live installs (or break a live
23 + * URL). The mismatch between this constant's name and its value is
24 + * deliberate — it is NOT a half-finished rename.
25 + */
26 +const OPENSTATION_OS_SETTINGS_META_KEY = 'desktop_mode_os_settings';
19 27
20 28 /** Valid dock-size IDs — mirrors the TS `DOCK_SIZES` constant. */
21 -const DESKTOP_MODE_OS_SETTINGS_DOCK_SIZES = array( 'compact', 'default', 'large' );
29 +const OPENSTATION_OS_SETTINGS_DOCK_SIZES = array( 'compact', 'default', 'large' );
22 30
31 +/** Valid window-radius IDs — mirrors the TS `WINDOW_RADII` constant. */
32 +const OPENSTATION_OS_SETTINGS_WINDOW_RADII = array( 'sharp', 'default', 'round' );
33 +
34 +/** 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 +
37 +/**
38 + * Valid admin-bar mode IDs — mirrors the TS `ADMIN_BAR_MODES` constant.
39 + *
40 + * `static` keeps the WordPress admin bar pinned above the shell (the
41 + * default), `dynamic` auto-hides it to a peek strip that reveals on
42 + * hover or keyboard focus, and `hidden` removes it entirely.
43 + */
44 +const OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES = array( 'static', 'dynamic', 'hidden' );
45 +
23 46 /** Valid desktop-layout IDs — mirrors the TS `DESKTOP_LAYOUTS` constant. */
24 -const DESKTOP_MODE_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified', 'spatial' );
47 +const OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS = array( 'classic', 'unified' );
25 48
26 49 /**
27 - * Valid AI live-progress transports — mirrors the TS `AI_TRANSPORTS` constant.
50 + * Valid dock-placement IDs — mirrors the TS `DOCK_PLACEMENTS` constant.
28 51 *
29 - * - `sse` — Server-Sent Events; real-time progress ticks. Requires the host
30 - * to allow long-lived `text/event-stream` connections.
31 - * - `off` — single request, no progress ticks. Works everywhere; the user
32 - * sees "Thinking…" until the final answer.
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.
33 60 *
34 - * Default is `off` because some hosts (locked-down shared environments,
35 - * proxies that buffer responses) silently drop SSE mid-stream, which surfaces
36 - * to the user as "Lost connection to the assistant".
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.
37 65 */
38 -const DESKTOP_MODE_OS_SETTINGS_AI_TRANSPORTS = array( 'sse', 'off' );
66 +const OPENSTATION_OS_SETTINGS_DOCK_BEHAVIORS = array( 'static', 'dynamic' );
39 67
40 68 /**
41 - * Built-in AI provider IDs.
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 +/**
79 + * Playable range for the window-reveal duration override, in ms.
80 + * Mirrors `MIN_REVEAL_DURATION_MS` / `MAX_REVEAL_DURATION_MS` in
81 + * `src/reveals/registry.ts`.
42 82 *
43 - * Other providers register themselves via {@see desktop_mode_register_ai_provider()};
44 - * sanitization no longer gates the field against this list (the active-provider
45 - * resolver does the existence check at lookup time).
46 - *
47 - * @deprecated 0.18.0 Kept for backwards compatibility; use the provider registry.
83 + * `0` sits OUTSIDE this range on purpose: it is the "no override"
84 + * sentinel, not a duration, and is handled before the clamp.
48 85 */
49 -const DESKTOP_MODE_OS_SETTINGS_AI_PROVIDERS = array( 'openai' );
86 +const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN = 80;
87 +const OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX = 4000;
50 88
51 89 /**
52 90 * Returns a well-shaped default OS settings array.
53 91 *
@@ -53,19 +91,97 @@
53 91 *
54 92 * Mirrors the TypeScript `DEFAULTS` constant so a fresh user account
55 93 * gets the same starting state in both environments.
56 94 *
57 - * @since 0.14.0
58 - *
59 95 * @return array
60 96 */
61 -function desktop_mode_default_os_settings() {
97 +function openstation_default_os_settings() {
62 98 return array(
63 - 'wallpaper' => 'dark',
64 - 'accent' => 'wp-blue',
99 + 'wallpaper' => 'galaxy',
100 + // Pulse, the brand's signature. Mirrors `DEFAULTS` in
101 + // `src/settings/constants.ts`.
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',
65 108 'dockSize' => 'default',
66 - 'desktopLayout' => 'classic',
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',
118 + // How the WordPress admin bar presents above the shell.
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',
67 139 'dockRailRenderer' => 'default',
140 + // Active desktop-theme slug, or `''` for the system default.
141 + // Site-wide library (`includes/desktop-themes/`), per-user
142 + // activation. Not validated against the installed list here —
143 + // the enqueue path checks existence on every request, so a
144 + // deleted theme degrades silently instead of needing a
145 + // user-meta rewrite.
146 + 'desktopTheme' => '',
147 + // Slugs of the desktop themes whose `recommendedOsSettings`
148 + // block has already been applied for this user. A theme's
149 + // recommendations are seeded ONCE — the first time the user
150 + // activates it — and this list is the record of that. It is
151 + // what makes "never overwrite a user's later choices" true:
152 + // re-activating a theme they have worn before changes
153 + // nothing. The Themes tab's "Apply recommended layout" action
154 + // is the deliberate way back. Capped at 64 slugs.
155 + 'appliedThemeRecommendations' => array(),
156 + 'unfocusEffect' => 'darken',
157 + // Window-reveal id — the clip-path transition that uncovers a
158 + // window's content once it finishes loading. Off by default;
159 + // `none` is the plain opacity fade the shell has always had.
160 + 'windowReveal' => 'none',
161 + // Global reveal duration override in ms. 0 means "use each
162 + // reveal's own tuned timing" — the shipped reveals have
163 + // durations chosen per shape (Radar's full turn is slower
164 + // than Sweep's straight line), and one flat number would
165 + // lose that.
166 + 'windowRevealDuration' => 0,
167 + // Window-link renderer id — how relation ties between windows
168 + // are drawn (see includes/window-links.php). `svg-splines` is
169 + // the shipped built-in; `none` disables the visuals.
170 + 'windowLinkRenderer' => 'svg-splines',
171 + // When the ties are visible: 'always' (default), 'focus' (only
172 + // while a group member is focused), or 'off'.
173 + 'windowLinkVisibility' => 'always',
174 + // Master switch for the window-links feature (OS Settings →
175 + // Features). Off unmounts the visuals AND the group behaviors
176 + // below; the style knobs above keep their values for when it
177 + // comes back on.
178 + 'windowLinksEnabled' => true,
179 + // Focusing a relation-group member raises its related windows
180 + // to just below it (silent restack, no focus theft).
181 + 'windowLinkRaiseOnFocus' => true,
182 + // Related windows of the focused member get a subtle outline.
183 + 'windowLinkHighlight' => true,
68 184 'customGradient' => array(
69 185 'from' => '#2271b1',
70 186 'to' => '#7c3aed',
71 187 'angle' => 135,
@@ -70,52 +186,70 @@
70 186 'to' => '#7c3aed',
71 187 'angle' => 135,
72 188 ),
73 189 'customImage' => null,
190 + // Per-wallpaper settings bags, keyed by wallpaper id — the
191 + // values a wallpaper's `renderConfig` dialog writes (e.g. the
192 + // Snow wallpaper's wind / particle count / flake size /
193 + // background). Scalar values only; the wallpaper owns the keys'
194 + // meaning. Missing ids mean "never configured" — the wallpaper
195 + // falls back to its defaults. Capped at 64 wallpapers × 32 keys.
196 + 'wallpaperSettings' => array(),
74 197 'libraryHdOnly' => true,
75 198 'ai' => array(
76 - 'enabled' => false,
77 - 'provider' => 'openai',
78 - 'apiKey' => '', // Legacy field — treated as the OpenAI key for backwards compat.
79 - 'apiKeys' => array(), // Per-provider keys: { [provider_id]: string }.
80 - 'transport' => 'off', // Live-progress transport: 'sse' | 'off'. Default off — see DESKTOP_MODE_OS_SETTINGS_AI_TRANSPORTS.
199 + 'enabled' => false, // AI assistant is opt-in; enabled from OS Settings → Features once a provider is configured.
81 200 ),
82 - // Per-user opt-OUT for the native Posts window. When true,
83 - // clicking the Posts dock tile opens the `<wpd-table>`-driven
201 + // Per-user opt-IN for the native Posts window. When true,
202 + // clicking the Posts dock tile opens the `<os-table>`-driven
84 203 // native window instead of the chromeless `edit.php` iframe.
85 - // Default ON as of 0.8.0 — the native UI is the canonical
86 - // Desktop Mode Posts experience; users can flip it off to fall
87 - // back to the classic iframe.
204 + // Default OFF — the native windows are opt-in
205 + // Beta. Fresh installs land on the classic iframe; users turn
206 + // this on in OS Settings → Features → Beta features to try it.
88 207 // Per-user override of the WordPress Heartbeat interval, in
89 - // seconds. 60s matches Core's "idle" default; values below
90 - // 15 force a lower `minimalInterval` too. See
91 - // `desktop_mode_apply_heartbeat_rate_setting` for the
208 + // seconds. 60s matches Core's "idle" default; the allowed
209 + // rates (15/30/45/60) all sit at or above Core's 15 s
210 + // `minimalInterval` floor. See
211 + // `openstation_apply_heartbeat_rate_setting` for the
92 212 // `heartbeat_settings` filter that applies this.
93 213 'heartbeatRate' => 60,
94 - 'nativePostsEnabled' => true,
214 + 'nativePostsEnabled' => false,
95 215 // Per-user list of column keys hidden in the native Posts
96 216 // window (e.g. array( 'author', 'tags' )). Empty array means
97 217 // every column is visible. The sticky 'title' column is always
98 218 // shown — the UI prevents toggling it.
99 219 'nativePostsHiddenColumns' => array(),
100 - // Per-user opt-OUT for the native Pages window. Same posture as
101 - // nativePostsEnabled — defaults ON, users can flip off to keep
102 - // the classic `edit.php?post_type=page` iframe.
103 - 'nativePagesEnabled' => true,
104 - // Per-user opt-OUT for the native Users window. Defaults ON;
105 - // the server-side cap gate (`list_users`) means the toggle
106 - // only matters for users who could see the Users tile anyway.
107 - 'nativeUsersEnabled' => true,
108 - // Per-user opt-OUT for the native Plugins window. Defaults ON;
109 - // the server-side cap gate (`activate_plugins`) means the
110 - // toggle only matters for users who could see the Plugins
111 - // tile anyway. When `false`, the dock click falls back to the
112 - // classic `plugins.php` chromeless iframe path.
113 - 'nativePluginsEnabled' => true,
114 - // Per-user opt-OUT for the native Comments window. Defaults ON;
115 - // the server-side cap gate (`edit_posts`) means the toggle only
116 - // matters for users who could see the Comments tile anyway.
117 - 'nativeCommentsEnabled' => true,
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(),
225 + // Per-user opt-IN for the native Pages window. Same posture as
226 + // nativePostsEnabled — defaults OFF (Beta), users opt in to swap
227 + // the classic `edit.php?post_type=page` iframe for the native UI.
228 + 'nativePagesEnabled' => false,
229 + // Per-user opt-IN for the native Users window. Defaults OFF
230 + // (Beta); the server-side cap gate (`list_users`) means the
231 + // toggle only matters for users who could see the Users tile.
232 + 'nativeUsersEnabled' => false,
233 + // Per-user opt-IN for the native Plugins window. Defaults OFF
234 + // (Beta); the server-side cap gate (`activate_plugins`) means
235 + // the toggle only matters for users who could see the Plugins
236 + // tile anyway. When `false`, the dock click uses the classic
237 + // `plugins.php` chromeless iframe path.
238 + 'nativePluginsEnabled' => false,
239 + // Per-user opt-IN for the native Comments window. Defaults OFF
240 + // (Beta); the server-side cap gate (`edit_posts`) means the
241 + // toggle only matters for users who could see the Comments tile.
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,
118 252 // When true, left-clicking the empty wallpaper triggers the
119 253 // "Show desktop" toggle (macOS-style) and the matching entry is
120 254 // hidden from the wallpaper context menu. When false (default),
121 255 // the entry stays in the menu and left clicks on the wallpaper
@@ -120,13 +254,43 @@
120 254 // hidden from the wallpaper context menu. When false (default),
121 255 // the entry stays in the menu and left clicks on the wallpaper
122 256 // do nothing. Per-user.
123 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,
263 + // Mio — a soft-body companion that floats over
264 + // the wallpaper, settles onto nearby windows, and watches the
265 + // pointer. Off by default; toggled from the wallpaper context
266 + // menu. Per-user. See `docs/mio.md`.
267 + 'mioEnabled' => false,
268 + 'mioApiEnabled' => false,
269 + 'mioShowOnWallpaper' => true,
270 + // The user's own Mio, as built in "Make it yours": partial
271 + // appearance + silhouette overrides, both empty until they
272 + // touch a control. Stored per user rather than per browser
273 + // because it is a preference about the person — ten minutes
274 + // spent building a companion should be waiting on their phone.
275 + // Sanitized by `openstation_sanitize_mio_look()`; the ranges
276 + // are enforced client-side in `sanitizeMioConfig()`.
277 + 'mioStyle' => array(
278 + 'appearance' => array(),
279 + 'physics' => array(),
280 + ),
124 281 // Diagonal corner ribbon on My WordPress tiles whose post
125 282 // status isn't `publish` (draft / pending / private /
126 283 // scheduled). On by default — surfaces unpublished work at
127 284 // a glance. Per-user.
128 285 'showPostStatusRibbons' => true,
286 + // Unlocks developer-facing surfaces meant for plugin
287 + // authors: the Starter Widget appears in the add-widget
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.
292 + 'developerModeEnabled' => false,
129 293 // Per-user opt-OUT for the folder-sharing feature. Defaults
130 294 // ON. When false:
131 295 // - The Share button, share-settings modal, "Leave shared
132 296 // folder" entry, and pending-invite prompt are all
@@ -140,23 +304,30 @@
140 304 // disappears without any database changes. The site-wide
141 305 // "Delete folder sharing data" action in OS Settings →
142 306 // Features → Advanced is a separate destructive cleanup.
143 307 'foldersSharingEnabled' => true,
144 - // Per-item placement preferences. Map of item id (dock-item
145 - // slug or registered desktop-icon id) → one of:
146 - // 'both' — show on both dock and desktop.
147 - // 'dock' — show only on the dock; hide from desktop.
148 - // '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.
149 313 // 'hidden' — hide from every shell surface.
150 - // 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`.
151 316 // Sanitized as map<sanitize_key, enum>. Capped at 256 entries.
152 - 'itemVisibility' => array(),
153 - // Per-user dock ordering. Ordered list of item ids; ids not in
154 - // the list keep their server-supplied position appended after
155 - // the listed ones. Unknown ids are tolerated.
156 - 'dockOrder' => array(),
157 - // Persisted desktop position for every dock item the user has
158 - // 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`.
159 330 // Keyed by item id, value is `{ x: int, y: int }`. The JS
160 331 // synthesizer reads this when building a synthetic placement so
161 332 // the icon lands where the user last dragged it instead of
162 333 // resetting to (0, 0) on every reload. Capped at 256 entries.
@@ -169,47 +340,111 @@
169 340 *
170 341 * Always returns a fully-shaped array so the JS side doesn't need to
171 342 * defend against partial or missing keys.
172 343 *
173 - * @since 0.14.0
174 - *
175 344 * @param int $user_id The user ID.
176 345 * @return array
177 346 */
178 -function desktop_mode_get_os_settings( $user_id ) {
347 +function openstation_get_os_settings( $user_id ) {
179 348 $user_id = (int) $user_id;
180 349 if ( $user_id <= 0 ) {
181 - return desktop_mode_default_os_settings();
350 + return openstation_sanitize_os_settings( array() );
182 351 }
183 352
184 - $raw = get_user_meta( $user_id, DESKTOP_MODE_OS_SETTINGS_META_KEY, true );
353 + $raw = get_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, true );
185 354 if ( ! is_array( $raw ) ) {
186 - return desktop_mode_default_os_settings();
355 + return openstation_sanitize_os_settings( array() );
187 356 }
188 357
189 - return desktop_mode_sanitize_os_settings( $raw );
358 + return openstation_sanitize_os_settings( $raw );
190 359 }
191 360
192 361 /**
193 362 * Saves sanitized OS settings for a user.
194 363 *
195 - * @since 0.14.0
196 - *
197 364 * @param int $user_id The user ID.
198 365 * @param mixed $settings Raw settings payload from the client.
199 366 * @return bool True on success, false otherwise.
200 367 */
201 -function desktop_mode_save_os_settings( $user_id, $settings ) {
368 +function openstation_save_os_settings( $user_id, $settings ) {
202 369 $user_id = (int) $user_id;
203 370 if ( $user_id <= 0 ) {
204 371 return false;
205 372 }
206 373
207 - $clean = desktop_mode_sanitize_os_settings( $settings );
208 - return false !== update_user_meta( $user_id, DESKTOP_MODE_OS_SETTINGS_META_KEY, $clean );
374 + $clean = openstation_sanitize_os_settings( $settings );
375 + return false !== update_user_meta( $user_id, OPENSTATION_OS_SETTINGS_META_KEY, $clean );
209 376 }
210 377
211 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 +/**
212 447 * Sanitizes a raw OS settings payload.
213 448 *
214 449 * Unknown keys are ignored; known keys are coerced field-by-field so a
215 450 * partial save (e.g., only accent changed) merges cleanly with the
@@ -214,25 +449,29 @@
214 449 * Unknown keys are ignored; known keys are coerced field-by-field so a
215 450 * partial save (e.g., only accent changed) merges cleanly with the
216 451 * defaults rather than wiping unset fields.
217 452 *
218 - * @since 0.14.0
219 - *
220 453 * @param mixed $raw Raw settings from the client or user meta.
221 454 * @return array Sanitized settings.
222 455 */
223 -function desktop_mode_sanitize_os_settings( $raw ) {
224 - $defaults = desktop_mode_default_os_settings();
456 +function openstation_sanitize_os_settings( $raw ) {
457 + $defaults = openstation_default_os_settings();
225 458
226 459 if ( ! is_array( $raw ) ) {
227 - return $defaults;
460 + $raw = array();
228 461 }
229 462
230 463 // Wallpaper — any non-empty string; registry membership is validated
231 464 // client-side at apply time.
232 - $wallpaper = isset( $raw['wallpaper'] ) && is_string( $raw['wallpaper'] ) && '' !== $raw['wallpaper']
233 - ? sanitize_key( $raw['wallpaper'] )
234 - : $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 + }
235 474
236 475 // Accent — non-empty string; swatch validity is enforced in the picker.
237 476 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
238 477 ? sanitize_key( $raw['accent'] )
@@ -237,25 +476,64 @@
237 476 $accent = isset( $raw['accent'] ) && is_string( $raw['accent'] ) && '' !== $raw['accent']
238 477 ? sanitize_key( $raw['accent'] )
239 478 : $defaults['accent'];
240 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 +
241 490 // Dock size — must be one of the three known values.
242 - $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], DESKTOP_MODE_OS_SETTINGS_DOCK_SIZES, true )
491 + $dock_size = isset( $raw['dockSize'] ) && in_array( $raw['dockSize'], OPENSTATION_OS_SETTINGS_DOCK_SIZES, true )
243 492 ? (string) $raw['dockSize']
244 493 : $defaults['dockSize'];
245 494
246 - // Desktop layout — must be one of the three known values
247 - // (`classic`, `unified`, `spatial`). Default `classic`.
495 + // Window radius — must be one of the three known values.
496 + $window_radius = isset( $raw['windowRadius'] ) && in_array( $raw['windowRadius'], OPENSTATION_OS_SETTINGS_WINDOW_RADII, true )
497 + ? (string) $raw['windowRadius']
498 + : $defaults['windowRadius'];
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 +
505 + // Admin-bar mode — must be one of the three known values.
506 + $admin_bar_mode = isset( $raw['adminBarMode'] )
507 + && in_array( $raw['adminBarMode'], OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES, true )
508 + ? (string) $raw['adminBarMode']
509 + : $defaults['adminBarMode'];
510 +
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`.
248 524 $desktop_layout = isset( $raw['desktopLayout'] )
249 - && in_array( $raw['desktopLayout'], DESKTOP_MODE_OS_SETTINGS_DESKTOP_LAYOUTS, true )
525 + && in_array( $raw['desktopLayout'], OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS, true )
250 526 ? (string) $raw['desktopLayout']
251 527 : $defaults['desktopLayout'];
252 528
253 - // Submenu renderer id — accept any sanitize_key()-clean string.
254 - // We don't gate on a server-side allow-list because renderers
255 - // register from JS at runtime; existence is checked by the
256 - // client at resolve time and falls back to `'default'` when
257 - // missing.
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 +
258 536 // Dock rail renderer id — accept any sanitize_key()-clean
259 537 // string. JS-side registry resolves at use time and falls back
260 538 // to `'default'` when the picked renderer isn't registered.
261 539 $dock_rail_renderer = $defaults['dockRailRenderer'];
@@ -265,8 +543,126 @@
265 543 $dock_rail_renderer = $slug;
266 544 }
267 545 }
268 546
547 + // Desktop theme slug — a pattern check, NOT an allow-list, the
548 + // same idiom as `dockRailRenderer` above. Validating against the
549 + // installed-theme option here would load (and unserialize) that
550 + // option on every single settings write for a value the enqueue
551 + // path re-checks anyway. `''` is the system default and is a
552 + // legitimate value, so an empty/absent key keeps the default.
553 + $desktop_theme = $defaults['desktopTheme'];
554 + if ( isset( $raw['desktopTheme'] ) && is_string( $raw['desktopTheme'] ) ) {
555 + $desktop_theme = sanitize_key( $raw['desktopTheme'] );
556 + }
557 +
558 + // appliedThemeRecommendations — list of desktop-theme slugs whose
559 + // recommendations this user has already been seeded with. Unknown
560 + // slugs are kept (a deleted-then-reinstalled theme must not
561 + // re-seed and clobber the settings the user has since chosen).
562 + $applied_theme_recommendations = $defaults['appliedThemeRecommendations'];
563 + if ( isset( $raw['appliedThemeRecommendations'] ) && is_array( $raw['appliedThemeRecommendations'] ) ) {
564 + $applied_theme_recommendations = array();
565 + foreach ( $raw['appliedThemeRecommendations'] as $theme_slug ) {
566 + if ( ! is_string( $theme_slug ) || '' === $theme_slug ) {
567 + continue;
568 + }
569 + $theme_slug = sanitize_key( $theme_slug );
570 + if ( '' === $theme_slug ) {
571 + continue;
572 + }
573 + $applied_theme_recommendations[] = $theme_slug;
574 + }
575 + // Keep the MOST RECENT 64, not the first 64 — the client
576 + // appends, so trimming from the front would silently discard
577 + // the entry that was just written and let the theme re-seed on
578 + // the next activation.
579 + $applied_theme_recommendations = array_slice(
580 + array_values( array_unique( $applied_theme_recommendations ) ),
581 + -64
582 + );
583 + }
584 +
585 + // Unfocus effect id — accept the `none` sentinel or any registry id.
586 + // Effect ids mirror the JS registry pattern `^[a-z0-9_/-]+$` (slashes
587 + // allowed for `vendor/sub-id` namespacing), so we lower-case and strip
588 + // to that charset rather than using sanitize_key() (which would drop
589 + // the slash and break a namespaced id on round-trip). No allow-list:
590 + // the JS engine resolves at use time and treats an unknown id as "no
591 + // effect".
592 + $unfocus_effect = $defaults['unfocusEffect'];
593 + if ( isset( $raw['unfocusEffect'] ) && is_string( $raw['unfocusEffect'] ) ) {
594 + $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['unfocusEffect'] ) );
595 + if ( '' !== $slug ) {
596 + $unfocus_effect = $slug;
597 + }
598 + }
599 +
600 + // Window-reveal id — same id charset and same no-allow-list
601 + // reasoning as the unfocus effect above. The JS surface resolves at
602 + // play time and treats an unknown id as "no reveal", so a reveal
603 + // belonging to a temporarily-deactivated plugin survives the
604 + // round-trip and starts working again the moment it re-registers.
605 + $window_reveal = $defaults['windowReveal'];
606 + if ( isset( $raw['windowReveal'] ) && is_string( $raw['windowReveal'] ) ) {
607 + $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowReveal'] ) );
608 + if ( '' !== $slug ) {
609 + $window_reveal = $slug;
610 + }
611 + }
612 +
613 + // Window-reveal duration override — 0 (the default) means "leave
614 + // each reveal's own timing alone". Anything else is clamped into
615 + // the playable range rather than rejected: a value past the end of
616 + // the range still expresses a direction, and the nearest playable
617 + // duration is the honest reading of it.
618 + $window_reveal_duration = $defaults['windowRevealDuration'];
619 + if ( isset( $raw['windowRevealDuration'] ) && is_numeric( $raw['windowRevealDuration'] ) ) {
620 + $requested = (int) round( (float) $raw['windowRevealDuration'] );
621 + if ( $requested > 0 ) {
622 + $window_reveal_duration = max(
623 + OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN,
624 + min( OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX, $requested )
625 + );
626 + } else {
627 + $window_reveal_duration = 0;
628 + }
629 + }
630 +
631 + // Window-link renderer id — same id charset as unfocus effects
632 + // (slashes allowed for `vendor/sub-id`). No allow-list: the JS
633 + // render host resolves at use time and falls back to the built-in
634 + // `svg-splines` when the picked renderer isn't registered.
635 + $window_link_renderer = $defaults['windowLinkRenderer'];
636 + if ( isset( $raw['windowLinkRenderer'] ) && is_string( $raw['windowLinkRenderer'] ) ) {
637 + $slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $raw['windowLinkRenderer'] ) );
638 + if ( '' !== $slug ) {
639 + $window_link_renderer = $slug;
640 + }
641 + }
642 +
643 + // Window-link visibility — small closed set.
644 + $window_link_visibility = $defaults['windowLinkVisibility'];
645 + if (
646 + isset( $raw['windowLinkVisibility'] )
647 + && in_array( $raw['windowLinkVisibility'], array( 'focus', 'always', 'off' ), true )
648 + ) {
649 + $window_link_visibility = $raw['windowLinkVisibility'];
650 + }
651 +
652 + // Window-links feature switches — plain booleans.
653 + $window_links_enabled = isset( $raw['windowLinksEnabled'] )
654 + ? (bool) $raw['windowLinksEnabled']
655 + : $defaults['windowLinksEnabled'];
656 +
657 + $window_link_raise_on_focus = isset( $raw['windowLinkRaiseOnFocus'] )
658 + ? (bool) $raw['windowLinkRaiseOnFocus']
659 + : $defaults['windowLinkRaiseOnFocus'];
660 +
661 + $window_link_highlight = isset( $raw['windowLinkHighlight'] )
662 + ? (bool) $raw['windowLinkHighlight']
663 + : $defaults['windowLinkHighlight'];
664 +
269 665 // Custom gradient — { from, to: valid hex; angle: int 0–360 }.
270 666 $custom_gradient = $defaults['customGradient'];
271 667 if ( isset( $raw['customGradient'] ) && is_array( $raw['customGradient'] ) ) {
272 668 $cg = $raw['customGradient'];
@@ -297,12 +693,66 @@
297 693 );
298 694 }
299 695 }
300 696
697 + // wallpaperSettings — map<wallpaper id, map<key, scalar>>. Wallpaper
698 + // ids follow the same charset as unfocus-effect ids (slashes allowed
699 + // for `vendor/sub-id` namespacing); setting keys follow the JS
700 + // identifier-ish charset wallpaper authors use (camelCase, hyphens,
701 + // underscores). Values must be scalar — booleans and numbers pass
702 + // through typed, strings are sanitized and length-capped. Unknown
703 + // wallpaper ids are kept (a deactivated wallpaper plugin's settings
704 + // should survive reactivation). Capped at 64 ids × 32 keys.
705 + $wallpaper_settings = array();
706 + if ( isset( $raw['wallpaperSettings'] ) && is_array( $raw['wallpaperSettings'] ) ) {
707 + $id_count = 0;
708 + foreach ( $raw['wallpaperSettings'] as $wp_id => $bag ) {
709 + if ( $id_count >= 64 ) {
710 + break;
711 + }
712 + if ( ! is_string( $wp_id ) || '' === $wp_id || ! is_array( $bag ) ) {
713 + continue;
714 + }
715 + $wp_slug = preg_replace( '/[^a-z0-9_\/-]/', '', strtolower( $wp_id ) );
716 + if ( '' === $wp_slug ) {
717 + continue;
718 + }
719 + $clean_bag = array();
720 + $key_count = 0;
721 + foreach ( $bag as $key => $value ) {
722 + if ( $key_count >= 32 ) {
723 + break;
724 + }
725 + if ( ! is_string( $key ) || '' === $key || ! preg_match( '/^[a-zA-Z0-9_-]+$/', $key ) ) {
726 + continue;
727 + }
728 + if ( is_bool( $value ) ) {
729 + $clean_bag[ $key ] = $value;
730 + } elseif ( is_int( $value ) || is_float( $value ) ) {
731 + if ( ! is_finite( (float) $value ) ) {
732 + continue;
733 + }
734 + $clean_bag[ $key ] = $value;
735 + } elseif ( is_string( $value ) ) {
736 + $clean_bag[ $key ] = mb_substr( sanitize_text_field( $value ), 0, 256 );
737 + } else {
738 + continue;
739 + }
740 + ++$key_count;
741 + }
742 + if ( empty( $clean_bag ) ) {
743 + continue;
744 + }
745 + $wallpaper_settings[ $wp_slug ] = $clean_bag;
746 + ++$id_count;
747 + }
748 + }
749 +
301 750 // Library HD only — boolean.
302 751 $library_hd_only = isset( $raw['libraryHdOnly'] ) ? (bool) $raw['libraryHdOnly'] : $defaults['libraryHdOnly'];
303 752
304 - // AI settings.
753 + // AI settings — just the per-user on/off toggle. Provider + model selection
754 + // is delegated to the Core AI Client, so there is no preference to persist.
305 755 $ai = $defaults['ai'];
306 756 if ( isset( $raw['ai'] ) && is_array( $raw['ai'] ) ) {
307 757 $raw_ai = $raw['ai'];
308 758
@@ -308,55 +758,12 @@
308 758
309 759 if ( isset( $raw_ai['enabled'] ) ) {
310 760 $ai['enabled'] = (bool) $raw_ai['enabled'];
311 761 }
312 -
313 - // Provider — accept any sanitize_key()-clean string. We don't gate
314 - // on the registry here because providers register on `init` and
315 - // sanitize may run earlier (REST boot). Existence is checked at
316 - // lookup time by `desktop_mode_ai_get_active_provider_id()`.
317 - if ( isset( $raw_ai['provider'] ) && is_string( $raw_ai['provider'] ) ) {
318 - $slug = sanitize_key( $raw_ai['provider'] );
319 - if ( '' !== $slug ) {
320 - $ai['provider'] = $slug;
321 - }
322 - }
323 -
324 - // API key — strip tags and limit length. The key is opaque to us;
325 - // we just store what the user gives. 512 chars is generous for any
326 - // real API key while preventing runaway meta writes.
327 - if ( isset( $raw_ai['apiKey'] ) && is_string( $raw_ai['apiKey'] ) ) {
328 - $ai['apiKey'] = substr( sanitize_text_field( $raw_ai['apiKey'] ), 0, 512 );
329 - }
330 -
331 - // Live-progress transport — must be one of the known values.
332 - if (
333 - isset( $raw_ai['transport'] )
334 - && is_string( $raw_ai['transport'] )
335 - && in_array( $raw_ai['transport'], DESKTOP_MODE_OS_SETTINGS_AI_TRANSPORTS, true )
336 - ) {
337 - $ai['transport'] = $raw_ai['transport'];
338 - }
339 -
340 - // Per-provider keys map. Limited to 32 entries to bound storage.
341 - if ( isset( $raw_ai['apiKeys'] ) && is_array( $raw_ai['apiKeys'] ) ) {
342 - $keys = array();
343 - foreach ( $raw_ai['apiKeys'] as $pid => $val ) {
344 - if ( count( $keys ) >= 32 ) {
345 - break;
346 - }
347 - $slug = sanitize_key( (string) $pid );
348 - if ( '' === $slug || ! is_string( $val ) ) {
349 - continue;
350 - }
351 - $keys[ $slug ] = substr( sanitize_text_field( $val ), 0, 512 );
352 - }
353 - $ai['apiKeys'] = $keys;
354 - }
355 762 }
356 763
357 764 // Heartbeat rate — one of the four allowed values. The PHP
358 - // filter `desktop_mode_apply_heartbeat_rate_setting` reads
765 + // filter `openstation_apply_heartbeat_rate_setting` reads
359 766 // this and passes it through to `heartbeat_settings` so
360 767 // WordPress Core itself reduces the interval on the next page
361 768 // load. 5 s is intentionally excluded: Core's
362 769 // `minimalInterval` floor clamps anything below 15 back up to
@@ -393,8 +800,27 @@
393 800 // user meta indefinitely.
394 801 $native_posts_hidden_columns = array_slice( array_values( array_unique( $native_posts_hidden_columns ) ), 0, 32 );
395 802 }
396 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 +
397 823 $native_pages_enabled = isset( $raw['nativePagesEnabled'] )
398 824 ? (bool) $raw['nativePagesEnabled']
399 825 : $defaults['nativePagesEnabled'];
400 826
@@ -409,28 +835,96 @@
409 835 $native_comments_enabled = isset( $raw['nativeCommentsEnabled'] )
410 836 ? (bool) $raw['nativeCommentsEnabled']
411 837 : $defaults['nativeCommentsEnabled'];
412 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 +
413 849 $show_desktop_on_wallpaper_click = isset( $raw['showDesktopOnWallpaperClick'] )
414 850 ? (bool) $raw['showDesktopOnWallpaperClick']
415 851 : $defaults['showDesktopOnWallpaperClick'];
416 852
853 + $confirm_close_all_windows = isset( $raw['confirmCloseAllWindows'] )
854 + ? (bool) $raw['confirmCloseAllWindows']
855 + : $defaults['confirmCloseAllWindows'];
856 +
857 + $mio_enabled = isset( $raw['mioEnabled'] )
858 + ? (bool) $raw['mioEnabled']
859 + : ( isset( $raw['mioApiEnabled'] ) ? (bool) $raw['mioApiEnabled'] : $defaults['mioEnabled'] );
860 +
861 + // A missing key means "no look saved yet", which sanitizes to the
862 + // same pair of empty arrays the defaults carry — so this needs no
863 + // isset() branch of its own.
864 + $mio_style = openstation_sanitize_mio_look(
865 + isset( $raw['mioStyle'] ) ? $raw['mioStyle'] : null
866 + );
867 +
417 868 $show_post_status_ribbons = isset( $raw['showPostStatusRibbons'] )
418 869 ? (bool) $raw['showPostStatusRibbons']
419 870 : $defaults['showPostStatusRibbons'];
420 871
872 + $developer_mode_enabled = isset( $raw['developerModeEnabled'] )
873 + ? (bool) $raw['developerModeEnabled']
874 + : $defaults['developerModeEnabled'];
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 +
421 903 $folders_sharing_enabled = isset( $raw['foldersSharingEnabled'] )
422 904 ? (bool) $raw['foldersSharingEnabled']
423 905 : $defaults['foldersSharingEnabled'];
424 906
425 - // itemVisibility — map<sanitize_key, enum>. Unknown ids are kept
907 + // navPlacement — map<sanitize_key, enum>. Unknown ids are kept
426 908 // (a deactivated plugin's setting should survive reactivation);
427 909 // invalid placement values are dropped.
428 - $item_visibility = array();
429 - if ( isset( $raw['itemVisibility'] ) && is_array( $raw['itemVisibility'] ) ) {
430 - $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' );
431 925 $count = 0;
432 - foreach ( $raw['itemVisibility'] as $key => $val ) {
926 + foreach ( $raw_placement as $key => $val ) {
433 927 if ( $count >= 256 ) {
434 928 break;
435 929 }
436 930 if ( ! is_string( $key ) || '' === $key || ! is_string( $val ) ) {
@@ -442,28 +936,38 @@
442 936 }
443 937 if ( ! in_array( $val, $allowed_placements, true ) ) {
444 938 continue;
445 939 }
446 - $item_visibility[ $slug ] = $val;
940 + $nav_placement[ $slug ] = $val;
447 941 ++$count;
448 942 }
449 943 }
450 944
451 - // dockOrder — ordered list of sanitize_key()-clean ids.
452 - $dock_order = array();
453 - 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 ) ) {
454 958 $seen = array();
455 - foreach ( $raw['dockOrder'] as $id ) {
959 + foreach ( $raw_order as $id ) {
456 960 if ( ! is_string( $id ) || '' === $id ) {
457 961 continue;
458 962 }
459 - $slug = sanitize_key( $id );
963 + $slug = sanitize_key( openstation_canonical_nav_id( $id ) );
460 964 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
461 965 continue;
462 966 }
463 967 $seen[ $slug ] = true;
464 - $dock_order[] = $slug;
465 - if ( count( $dock_order ) >= 256 ) {
968 + $nav_order[] = $slug;
969 + if ( count( $nav_order ) >= 256 ) {
466 970 break;
467 971 }
468 972 }
469 973 }
@@ -511,13 +1015,31 @@
511 1015
512 1016 return array(
513 1017 'wallpaper' => $wallpaper,
514 1018 'accent' => $accent,
1019 + 'customAccent' => $custom_accent,
515 1020 'dockSize' => $dock_size,
1021 + 'windowRadius' => $window_radius,
1022 + 'openWindowsAs' => $open_windows_as,
1023 + 'adminBarMode' => $admin_bar_mode,
516 1024 'desktopLayout' => $desktop_layout,
1025 + 'dockPlacement' => $dock_placement,
1026 + 'dockBehavior' => $dock_behavior,
1027 + 'sideDockBehavior' => $side_dock_behavior,
517 1028 'dockRailRenderer' => $dock_rail_renderer,
1029 + 'desktopTheme' => $desktop_theme,
1030 + 'appliedThemeRecommendations' => $applied_theme_recommendations,
1031 + 'unfocusEffect' => $unfocus_effect,
1032 + 'windowReveal' => $window_reveal,
1033 + 'windowRevealDuration' => $window_reveal_duration,
1034 + 'windowLinkRenderer' => $window_link_renderer,
1035 + 'windowLinkVisibility' => $window_link_visibility,
1036 + 'windowLinksEnabled' => $window_links_enabled,
1037 + 'windowLinkRaiseOnFocus' => $window_link_raise_on_focus,
1038 + 'windowLinkHighlight' => $window_link_highlight,
518 1039 'customGradient' => $custom_gradient,
519 1040 'customImage' => $custom_image,
1041 + 'wallpaperSettings' => $wallpaper_settings,
520 1042 'libraryHdOnly' => $library_hd_only,
521 1043 'ai' => $ai,
522 1044 'heartbeatRate' => $heartbeat_rate,
523 1045 'nativePostsEnabled' => $native_posts_enabled,
@@ -522,16 +1044,28 @@
522 1044 'heartbeatRate' => $heartbeat_rate,
523 1045 'nativePostsEnabled' => $native_posts_enabled,
524 1046 'nativePostsHiddenColumns' => $native_posts_hidden_columns,
525 1047 'nativePagesEnabled' => $native_pages_enabled,
1048 + 'nativePagesHiddenColumns' => $native_pages_hidden_columns,
526 1049 'nativeUsersEnabled' => $native_users_enabled,
527 1050 'nativePluginsEnabled' => $native_plugins_enabled,
528 1051 'nativeCommentsEnabled' => $native_comments_enabled,
1052 + 'stationHomeEnabled' => $station_home_enabled,
1053 + 'adminAssetCacheEnabled' => $admin_asset_cache_enabled,
1054 + 'windowPrewarmEnabled' => $window_prewarm_enabled,
529 1055 'showDesktopOnWallpaperClick' => $show_desktop_on_wallpaper_click,
1056 + 'confirmCloseAllWindows' => $confirm_close_all_windows,
1057 + 'mioEnabled' => $mio_enabled,
1058 + 'mioApiEnabled' => $mio_enabled,
1059 + 'mioShowOnWallpaper' => isset( $raw['mioShowOnWallpaper'] ) ? (bool) $raw['mioShowOnWallpaper'] : $defaults['mioShowOnWallpaper'],
1060 + 'mioStyle' => $mio_style,
530 1061 'showPostStatusRibbons' => $show_post_status_ribbons,
1062 + 'developerModeEnabled' => $developer_mode_enabled,
531 1063 'foldersSharingEnabled' => $folders_sharing_enabled,
532 - 'itemVisibility' => $item_visibility,
533 - 'dockOrder' => $dock_order,
1064 + 'navPlacement' => $nav_placement,
1065 + 'navOrder' => $nav_order,
1066 + 'mobileLayout' => $mobile_layout,
1067 + 'mobileTabs' => $mobile_tabs,
534 1068 'dockPromotedPositions' => $dock_promoted_positions,
535 1069 );
536 1070 }
537 1071
@@ -536,12 +1070,10 @@
536 1070 }
537 1071
538 1072 /**
539 1073 * Registers the REST routes for OS settings.
540 - *
541 - * @since 0.14.0
542 1074 */
543 -function desktop_mode_register_os_settings_rest_routes() {
1075 +function openstation_register_os_settings_rest_routes() {
544 1076 register_rest_route(
545 1077 'desktop-mode/v1',
546 1078 '/os-settings',
547 1079 array(
@@ -546,15 +1078,15 @@
546 1078 '/os-settings',
547 1079 array(
548 1080 array(
549 1081 'methods' => WP_REST_Server::READABLE,
550 - 'callback' => 'desktop_mode_rest_get_os_settings',
551 - 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
1082 + 'callback' => 'openstation_rest_get_os_settings',
1083 + 'permission_callback' => 'openstation_rest_os_settings_permission',
552 1084 ),
553 1085 array(
554 1086 'methods' => WP_REST_Server::CREATABLE,
555 - 'callback' => 'desktop_mode_rest_save_os_settings',
556 - 'permission_callback' => 'desktop_mode_rest_os_settings_permission',
1087 + 'callback' => 'openstation_rest_save_os_settings',
1088 + 'permission_callback' => 'openstation_rest_os_settings_permission',
557 1089 'args' => array(
558 1090 'settings' => array(
559 1091 'required' => true,
560 1092 'type' => 'object',
@@ -563,68 +1095,106 @@
563 1095 ),
564 1096 )
565 1097 );
566 1098 }
567 -add_action( 'rest_api_init', 'desktop_mode_register_os_settings_rest_routes' );
1099 +add_action( 'rest_api_init', 'openstation_register_os_settings_rest_routes' );
568 1100
569 1101 /**
570 1102 * Permission gate for OS settings REST routes.
571 1103 *
572 - * Requires the caller to be logged in *and* have desktop mode enabled —
573 - * see {@see desktop_mode_rest_require_enabled()} for why `read` alone is
1104 + * Requires the caller to be logged in *and* have OpenStation enabled —
1105 + * see {@see openstation_rest_require_enabled()} for why `read` alone is
574 1106 * insufficient.
575 1107 *
576 - * @since 0.8.10 Hardened to require desktop mode enabled (was `read`).
577 - *
578 1108 * @return true|WP_Error
579 1109 */
580 -function desktop_mode_rest_os_settings_permission() {
581 - return desktop_mode_rest_require_enabled();
1110 +function openstation_rest_os_settings_permission() {
1111 + return openstation_rest_require_enabled();
582 1112 }
583 1113
584 1114 /**
585 1115 * GET /desktop-mode/v1/os-settings
586 1116 *
587 - * @since 0.14.0
588 - *
589 1117 * @return WP_REST_Response
590 1118 */
591 -function desktop_mode_rest_get_os_settings() {
592 - return rest_ensure_response( desktop_mode_get_os_settings( get_current_user_id() ) );
1119 +function openstation_rest_get_os_settings() {
1120 + return rest_ensure_response( openstation_get_os_settings( get_current_user_id() ) );
593 1121 }
594 1122
595 1123 /**
596 1124 * POST /desktop-mode/v1/os-settings
597 1125 *
598 - * @since 0.14.0
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.
599 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 + *
600 1150 * @param WP_REST_Request $request The REST request.
601 1151 * @return WP_REST_Response The saved settings (after sanitization).
602 1152 */
603 -function desktop_mode_rest_save_os_settings( WP_REST_Request $request ) {
1153 +function openstation_rest_save_os_settings( WP_REST_Request $request ) {
604 1154 $user_id = get_current_user_id();
605 1155 $payload = $request->get_param( 'settings' );
606 - desktop_mode_save_os_settings( $user_id, $payload );
607 - return rest_ensure_response( desktop_mode_get_os_settings( $user_id ) );
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 + );
1180 + return rest_ensure_response( openstation_get_os_settings( $user_id ) );
608 1181 }
609 1182
610 1183 /**
611 1184 * Apply the per-user Heartbeat-rate preference to the
612 1185 * `heartbeat_settings` Core filter. WordPress reads these settings
613 - * once at page load to size both the initial AJAX interval and the
614 - * floor (`minimalInterval`) that prevents JS from speeding things
615 - * up. We mirror both so a 5-second rate actually fires every five
616 - * seconds (Core's default floor is 15).
1186 + * once at page load. We set `interval` only; the allowed rates
1187 + * (15/30/45/60 s) all sit at or above Core's 15 s
1188 + * `minimalInterval` floor, so the floor never needs overriding.
617 1189 *
618 - * Only applies to users with Desktop Mode enabled — non-desktop
1190 + * Only applies to users with OpenStation enabled — non-desktop
619 1191 * sessions keep Core's defaults. Anonymous requests skip too.
620 1192 *
621 - * @since 0.18.0
622 - *
623 1193 * @param array $settings Filtered Heartbeat settings.
624 1194 * @return array
625 1195 */
626 -function desktop_mode_apply_heartbeat_rate_setting( $settings ) {
1196 +function openstation_apply_heartbeat_rate_setting( $settings ) {
627 1197 if ( ! is_array( $settings ) ) {
628 1198 $settings = array();
629 1199 }
630 1200 $user_id = get_current_user_id();
@@ -630,12 +1200,12 @@
630 1200 $user_id = get_current_user_id();
631 1201 if ( $user_id <= 0 ) {
632 1202 return $settings;
633 1203 }
634 - if ( function_exists( 'desktop_mode_is_enabled' ) && ! desktop_mode_is_enabled( $user_id ) ) {
1204 + if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( $user_id ) ) {
635 1205 return $settings;
636 1206 }
637 - $os = desktop_mode_get_os_settings( $user_id );
1207 + $os = openstation_get_os_settings( $user_id );
638 1208 $rate = isset( $os['heartbeatRate'] ) ? (int) $os['heartbeatRate'] : 0;
639 1209 if ( ! in_array( $rate, array( 15, 30, 45, 60 ), true ) ) {
640 1210 return $settings;
641 1211 }
@@ -641,5 +1211,5 @@
641 1211 }
642 1212 $settings['interval'] = $rate;
643 1213 return $settings;
644 1214 }
645 -add_filter( 'heartbeat_settings', 'desktop_mode_apply_heartbeat_rate_setting' );
1215 +add_filter( 'heartbeat_settings', 'openstation_apply_heartbeat_rate_setting' );