| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Desktop Mode — Games registry. | |
| 3 | + * OpenStation — Games registry. | |
| 4 | 4 | * |
| 5 | 5 | * Server-side registration API + payload builder for desktop games. |
| 6 | 6 | * A game's discovery metadata (title, icon, description, score |
| 7 | 7 | * columns) is declared here in PHP so the Games window and the |
| @@ -8,9 +8,9 @@ | ||
| 8 | 8 | * scoreboard tabs paint at shell boot without downloading any game |
| 9 | 9 | * code; the game's JS bundle — declared via the `script` handle — |
| 10 | 10 | * is loaded lazily on first launch and publishes the full def |
| 11 | 11 | * (including the `render` callback) on |
| 12 | - * `window.desktopModeGames[ <id> ]`. | |
| 12 | + * `window.openStationGames[ <id> ]`. | |
| 13 | 13 | * |
| 14 | 14 | * This deliberate laziness is the one way the games registry differs |
| 15 | 15 | * from the wallpaper registry it is otherwise modeled on: wallpaper |
| 16 | 16 | * scripts are enqueued eagerly because the active wallpaper must |
| @@ -15,9 +15,9 @@ | ||
| 15 | 15 | * from the wallpaper registry it is otherwise modeled on: wallpaper |
| 16 | 16 | * scripts are enqueued eagerly because the active wallpaper must |
| 17 | 17 | * paint at boot; game code is only needed when someone plays. |
| 18 | 18 | * |
| 19 | - * @package WPDesktopMode | |
| 19 | + * @package OpenStation | |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | defined( 'ABSPATH' ) || exit; |
| 23 | 23 | |
| @@ -26,13 +26,13 @@ | ||
| 26 | 26 | * |
| 27 | 27 | * Example: |
| 28 | 28 | * |
| 29 | 29 | * ```php |
| 30 | - * desktop_mode_register_game( 'inkfall', array( | |
| 30 | + * openstation_register_game( 'inkfall', array( | |
| 31 | 31 | * 'title' => __( 'Inkfall', 'desktop-mode' ), |
| 32 | 32 | * 'description' => __( 'Type the falling words.', 'desktop-mode' ), |
| 33 | 33 | * 'icon_svg' => '<svg …>…</svg>', |
| 34 | - * 'script' => 'desktop-mode-game-inkfall', | |
| 34 | + * 'script' => 'os-game-inkfall', | |
| 35 | 35 | * 'score_columns' => array( |
| 36 | 36 | * array( 'key' => 'score', 'label' => __( 'Score', 'desktop-mode' ), 'type' => 'number' ), |
| 37 | 37 | * array( 'key' => 'time', 'label' => __( 'Time', 'desktop-mode' ), 'type' => 'time' ), |
| 38 | 38 | * ), |
| @@ -40,11 +40,11 @@ | ||
| 40 | 40 | * ) ); |
| 41 | 41 | * ``` |
| 42 | 42 | * |
| 43 | 43 | * ```js |
| 44 | - * // Inside desktop-mode-game-inkfall.js | |
| 45 | - * window.desktopModeGames = window.desktopModeGames || {}; | |
| 46 | - * window.desktopModeGames.inkfall = { | |
| 44 | + * // Inside os-game-inkfall.js | |
| 45 | + * window.openStationGames = window.openStationGames || {}; | |
| 46 | + * window.openStationGames.inkfall = { | |
| 47 | 47 | * id: 'inkfall', |
| 48 | 48 | * title: 'Inkfall', |
| 49 | 49 | * icon: 'data:image/svg+xml;base64,…', |
| 50 | 50 | * scoreColumns: [ … ], |
| @@ -52,9 +52,9 @@ | ||
| 52 | 52 | * }; |
| 53 | 53 | * ``` |
| 54 | 54 | * |
| 55 | 55 | * @param string $id Game id (slug). Must match the |
| 56 | - * `window.desktopModeGames[<id>]` key the game's | |
| 56 | + * `window.openStationGames[<id>]` key the game's | |
| 57 | 57 | * JS publishes. |
| 58 | 58 | * @param array $args { |
| 59 | 59 | * @type string $title Launcher label. Required. |
| 60 | 60 | * @type string $description Plain-text description shown on the |
| @@ -75,16 +75,29 @@ | ||
| 75 | 75 | * underneath (`wordsUrl` — see |
| 76 | 76 | * includes/games/config.php); the |
| 77 | 77 | * game's keys win on collision. |
| 78 | 78 | * @type string[] $capabilities Gate: ALL caps must match. |
| 79 | + * @type array $window The game window's size, as any | |
| 80 | + * subset of `{ width, height, | |
| 81 | + * minWidth, minHeight }` in pixels. | |
| 82 | + * Declare it here as well as in the | |
| 83 | + * JS def: a game's bundle is fetched | |
| 84 | + * on first play, so the shell opens | |
| 85 | + * the window — and paints its loading | |
| 86 | + * spinner — before it has seen the | |
| 87 | + * def. Without this the first window | |
| 88 | + * of a session opens at the framework | |
| 89 | + * default. The def still wins once it | |
| 90 | + * arrives, so declaring only in JS | |
| 91 | + * keeps working. | |
| 79 | 92 | * } |
| 80 | 93 | * @return true|WP_Error `true` on success; `WP_Error` otherwise. |
| 81 | 94 | */ |
| 82 | -function desktop_mode_register_game( $id, $args = array() ) { | |
| 95 | +function openstation_register_game( $id, $args = array() ) { | |
| 83 | 96 | $id = sanitize_key( (string) $id ); |
| 84 | 97 | if ( '' === $id ) { |
| 85 | - return desktop_mode_registration_error( | |
| 86 | - 'desktop_mode_missing_id', | |
| 98 | + return openstation_registration_error( | |
| 99 | + 'openstation_missing_id', | |
| 87 | 100 | __( 'Game id is required and must be a valid slug.', 'desktop-mode' ) |
| 88 | 101 | ); |
| 89 | 102 | } |
| 90 | 103 | |
| @@ -96,10 +109,11 @@ | ||
| 96 | 109 | 'script' => '', |
| 97 | 110 | 'score_columns' => array(), |
| 98 | 111 | 'config' => array(), |
| 99 | 112 | 'capabilities' => array(), |
| 113 | + 'window' => array(), | |
| 100 | 114 | ); |
| 101 | - $args = wp_parse_args( $args, $defaults ); | |
| 115 | + $args = wp_parse_args( $args, $defaults ); | |
| 102 | 116 | |
| 103 | 117 | $svg = trim( (string) $args['icon_svg'] ); |
| 104 | 118 | if ( '' !== $svg ) { |
| 105 | 119 | // Same defence-in-depth as desktop icons: the data URI is |
| @@ -105,17 +119,17 @@ | ||
| 105 | 119 | // Same defence-in-depth as desktop icons: the data URI is |
| 106 | 120 | // consumed via `<img src=…>` (which sandboxes SVG scripts), |
| 107 | 121 | // but reject script tags outright anyway. |
| 108 | 122 | if ( false !== stripos( $svg, '<script' ) ) { |
| 109 | - return desktop_mode_registration_error( | |
| 110 | - 'desktop_mode_invalid_icon_svg', | |
| 123 | + return openstation_registration_error( | |
| 124 | + 'openstation_invalid_icon_svg', | |
| 111 | 125 | __( 'Game `icon_svg` must not contain a <script> tag.', 'desktop-mode' ), |
| 112 | 126 | array( 'id' => $id ) |
| 113 | 127 | ); |
| 114 | 128 | } |
| 115 | 129 | if ( 0 !== stripos( ltrim( $svg ), '<svg' ) ) { |
| 116 | - return desktop_mode_registration_error( | |
| 117 | - 'desktop_mode_invalid_icon_svg', | |
| 130 | + return openstation_registration_error( | |
| 131 | + 'openstation_invalid_icon_svg', | |
| 118 | 132 | __( 'Game `icon_svg` must start with a <svg> root element.', 'desktop-mode' ), |
| 119 | 133 | array( 'id' => $id ) |
| 120 | 134 | ); |
| 121 | 135 | } |
| @@ -123,30 +137,33 @@ | ||
| 123 | 137 | } |
| 124 | 138 | |
| 125 | 139 | foreach ( (array) $args['capabilities'] as $cap ) { |
| 126 | 140 | if ( ! current_user_can( (string) $cap ) ) { |
| 127 | - return desktop_mode_registration_error( | |
| 128 | - 'desktop_mode_capability_denied', | |
| 141 | + return openstation_registration_error( | |
| 142 | + 'openstation_capability_denied', | |
| 129 | 143 | sprintf( |
| 130 | 144 | /* translators: %s: capability slug. */ |
| 131 | 145 | __( 'Current user lacks the %s capability required to register this game.', 'desktop-mode' ), |
| 132 | 146 | (string) $cap |
| 133 | 147 | ), |
| 134 | - array( 'capability' => (string) $cap, 'id' => $id ) | |
| 148 | + array( | |
| 149 | + 'capability' => (string) $cap, | |
| 150 | + 'id' => $id, | |
| 151 | + ) | |
| 135 | 152 | ); |
| 136 | 153 | } |
| 137 | 154 | } |
| 138 | 155 | |
| 139 | 156 | if ( '' === (string) $args['title'] ) { |
| 140 | - return desktop_mode_registration_error( | |
| 141 | - 'desktop_mode_missing_title', | |
| 157 | + return openstation_registration_error( | |
| 158 | + 'openstation_missing_title', | |
| 142 | 159 | __( 'Game registration requires a non-empty `title`.', 'desktop-mode' ), |
| 143 | 160 | array( 'id' => $id ) |
| 144 | 161 | ); |
| 145 | 162 | } |
| 146 | 163 | if ( '' === (string) $args['script'] ) { |
| 147 | - return desktop_mode_registration_error( | |
| 148 | - 'desktop_mode_missing_script', | |
| 164 | + return openstation_registration_error( | |
| 165 | + 'openstation_missing_script', | |
| 149 | 166 | __( 'Game registration requires a `script` handle that publishes the game def.', 'desktop-mode' ), |
| 150 | 167 | array( 'id' => $id ) |
| 151 | 168 | ); |
| 152 | 169 | } |
| @@ -154,30 +171,79 @@ | ||
| 154 | 171 | $entry = array( |
| 155 | 172 | 'id' => $id, |
| 156 | 173 | 'title' => (string) $args['title'], |
| 157 | 174 | 'description' => sanitize_textarea_field( (string) $args['description'] ), |
| 158 | - 'icon' => desktop_mode_sanitize_dock_icon( (string) $args['icon'] ), | |
| 175 | + 'icon' => openstation_sanitize_dock_icon( (string) $args['icon'] ), | |
| 159 | 176 | 'script' => (string) $args['script'], |
| 160 | - 'score_columns' => desktop_mode_games_sanitize_score_columns( $args['score_columns'] ), | |
| 177 | + 'score_columns' => openstation_games_sanitize_score_columns( $args['score_columns'] ), | |
| 161 | 178 | 'config' => is_array( $args['config'] ) ? $args['config'] : array(), |
| 179 | + 'window' => openstation_games_sanitize_window( $args['window'] ), | |
| 162 | 180 | ); |
| 163 | - desktop_mode_games_registry( $id, $entry ); | |
| 181 | + openstation_games_registry( $id, $entry ); | |
| 164 | 182 | |
| 165 | 183 | /** |
| 166 | 184 | * Fires after a desktop game is successfully registered. |
| 167 | 185 | * |
| 168 | - * Does NOT fire when `desktop_mode_register_game()` returns a | |
| 186 | + * Does NOT fire when `openstation_register_game()` returns a | |
| 169 | 187 | * `WP_Error`. |
| 170 | 188 | * |
| 171 | 189 | * @param string $id The game id. |
| 172 | 190 | * @param array $entry The stored registry entry. |
| 173 | 191 | */ |
| 174 | - do_action( 'desktop_mode_game_registered', $id, $entry ); | |
| 192 | + do_action( 'openstation_game_registered', $id, $entry ); | |
| 175 | 193 | |
| 176 | 194 | return true; |
| 177 | 195 | } |
| 178 | 196 | |
| 179 | 197 | /** |
| 198 | + * Normalize the `window` declaration — the game window's size, known | |
| 199 | + * before its bundle is. | |
| 200 | + * | |
| 201 | + * **Why this is registered server-side at all**, when the JS def | |
| 202 | + * already carries a `window` block: a game's bundle is heavyweight | |
| 203 | + * (the game, its engine, sometimes a dictionary asset), so it is | |
| 204 | + * fetched on first play rather than at boot. The shell therefore has | |
| 205 | + * to open the window — and paint its loading spinner — *before* it has | |
| 206 | + * ever seen the def. Without a size here that first window opens at | |
| 207 | + * the framework default and would have to jump to the real size once | |
| 208 | + * the def landed. With it, the size is right from the first frame. | |
| 209 | + * | |
| 210 | + * The JS def still wins once it arrives, so a game that declares only | |
| 211 | + * in JS keeps working exactly as before; it simply gets the default | |
| 212 | + * size on the first open of a session. | |
| 213 | + * | |
| 214 | + * Values are clamped rather than rejected: a nonsensical size is a | |
| 215 | + * plugin bug that should not stop the game opening, and an unopenable | |
| 216 | + * window is a worse answer than an oddly-sized one. | |
| 217 | + * | |
| 218 | + * @internal | |
| 219 | + * | |
| 220 | + * @param mixed $window Raw caller input. | |
| 221 | + * @return array Sanitized subset of `{ width, height, minWidth, minHeight }`. | |
| 222 | + */ | |
| 223 | +function openstation_games_sanitize_window( $window ) { | |
| 224 | + if ( ! is_array( $window ) ) { | |
| 225 | + return array(); | |
| 226 | + } | |
| 227 | + $out = array(); | |
| 228 | + foreach ( array( 'width', 'height', 'minWidth', 'minHeight' ) as $key ) { | |
| 229 | + if ( ! isset( $window[ $key ] ) || ! is_numeric( $window[ $key ] ) ) { | |
| 230 | + continue; | |
| 231 | + } | |
| 232 | + $value = (int) $window[ $key ]; | |
| 233 | + if ( $value <= 0 ) { | |
| 234 | + continue; | |
| 235 | + } | |
| 236 | + // The ceiling is generous on purpose — it exists to catch a | |
| 237 | + // typo'd pixel value, not to have an opinion about how big a | |
| 238 | + // game may be. The window manager clamps to the viewport anyway. | |
| 239 | + $out[ $key ] = min( $value, 10000 ); | |
| 240 | + } | |
| 241 | + | |
| 242 | + return $out; | |
| 243 | +} | |
| 244 | + | |
| 245 | +/** | |
| 180 | 246 | * Normalize the `score_columns` declaration: drop rows without a |
| 181 | 247 | * valid key, default labels to the key, and clamp `type` to the |
| 182 | 248 | * supported set. |
| 183 | 249 | * |
| @@ -185,9 +251,9 @@ | ||
| 185 | 251 | * |
| 186 | 252 | * @param mixed $columns Raw caller input. |
| 187 | 253 | * @return array[] Sanitized `{ key, label, type }` rows. |
| 188 | 254 | */ |
| 189 | -function desktop_mode_games_sanitize_score_columns( $columns ) { | |
| 255 | +function openstation_games_sanitize_score_columns( $columns ) { | |
| 190 | 256 | if ( ! is_array( $columns ) ) { |
| 191 | 257 | return array(); |
| 192 | 258 | } |
| 193 | 259 | $out = array(); |
| @@ -214,14 +280,14 @@ | ||
| 214 | 280 | } |
| 215 | 281 | |
| 216 | 282 | /** |
| 217 | 283 | * Internal module-level registry for games registered via |
| 218 | - * {@see desktop_mode_register_game()}. Same static-store pattern as | |
| 284 | + * {@see openstation_register_game()}. Same static-store pattern as | |
| 219 | 285 | * the widget + wallpaper + native-window registries. |
| 220 | 286 | * |
| 221 | 287 | * @internal |
| 222 | 288 | */ |
| 223 | -function desktop_mode_games_registry( $id = '', $entry = null ) { | |
| 289 | +function openstation_games_registry( $id = '', $entry = null ) { | |
| 224 | 290 | static $store = array(); |
| 225 | 291 | |
| 226 | 292 | if ( '' === (string) $id ) { |
| 227 | 293 | return $store; |
| @@ -242,36 +308,36 @@ | ||
| 242 | 308 | * |
| 243 | 309 | * @param string $id Game id. |
| 244 | 310 | * @return bool Whether an entry was removed. |
| 245 | 311 | */ |
| 246 | -function desktop_mode_unregister_game( $id ) { | |
| 312 | +function openstation_unregister_game( $id ) { | |
| 247 | 313 | $id = sanitize_key( (string) $id ); |
| 248 | - if ( '' === $id || null === desktop_mode_games_registry( $id ) ) { | |
| 314 | + if ( '' === $id || null === openstation_games_registry( $id ) ) { | |
| 249 | 315 | return false; |
| 250 | 316 | } |
| 251 | - desktop_mode_games_registry( $id, '__unset__' ); | |
| 317 | + openstation_games_registry( $id, '__unset__' ); | |
| 252 | 318 | return true; |
| 253 | 319 | } |
| 254 | 320 | |
| 255 | 321 | /** |
| 256 | - * The registered game entries with the `desktop_mode_games` filter | |
| 322 | + * The registered game entries with the `openstation_games` filter | |
| 257 | 323 | * applied. This is the read path everything else (payload, REST |
| 258 | 324 | * validation) goes through, so filter-registered games validate. |
| 259 | 325 | * |
| 260 | 326 | * @return array[] Entries keyed by game id. |
| 261 | 327 | */ |
| 262 | -function desktop_mode_games_get_registered() { | |
| 263 | - $registry = desktop_mode_games_registry(); | |
| 328 | +function openstation_games_get_registered() { | |
| 329 | + $registry = openstation_games_registry(); | |
| 264 | 330 | |
| 265 | 331 | /** |
| 266 | 332 | * Filters the server-declared game list. Mirrors the JS-side |
| 267 | - * `desktop-mode.games` filter so plugins can add, hide, or | |
| 333 | + * `os.games` filter so plugins can add, hide, or | |
| 268 | 334 | * override entries at boot without round-tripping through the |
| 269 | 335 | * JS registry. |
| 270 | 336 | * |
| 271 | 337 | * @param array[] $registry The registered game entries, keyed by id. |
| 272 | 338 | */ |
| 273 | - $registry = apply_filters( 'desktop_mode_games', $registry ); | |
| 339 | + $registry = apply_filters( 'openstation_games', $registry ); | |
| 274 | 340 | |
| 275 | 341 | return is_array( $registry ) ? $registry : array(); |
| 276 | 342 | } |
| 277 | 343 | |
| @@ -281,14 +347,14 @@ | ||
| 281 | 347 | * |
| 282 | 348 | * @param string $id Game id. |
| 283 | 349 | * @return bool |
| 284 | 350 | */ |
| 285 | -function desktop_mode_games_is_registered( $id ) { | |
| 351 | +function openstation_games_is_registered( $id ) { | |
| 286 | 352 | $id = sanitize_key( (string) $id ); |
| 287 | 353 | if ( '' === $id ) { |
| 288 | 354 | return false; |
| 289 | 355 | } |
| 290 | - $registry = desktop_mode_games_get_registered(); | |
| 356 | + $registry = openstation_games_get_registered(); | |
| 291 | 357 | if ( isset( $registry[ $id ] ) ) { |
| 292 | 358 | return true; |
| 293 | 359 | } |
| 294 | 360 | // Filter authors may return a plain list instead of an id-keyed |
| @@ -307,15 +373,15 @@ | ||
| 307 | 373 | * announced via the JS global its (lazily loaded) script sets up. |
| 308 | 374 | * |
| 309 | 375 | * @return array[] |
| 310 | 376 | */ |
| 311 | -function desktop_mode_build_desktop_games_payload() { | |
| 377 | +function openstation_build_desktop_games_payload() { | |
| 312 | 378 | // The module doesn't load when the framework is disabled, so this |
| 313 | 379 | // only guards a mid-request flip (the admin just saved the toggle). |
| 314 | - if ( ! desktop_mode_games_enabled() ) { | |
| 380 | + if ( ! openstation_games_enabled() ) { | |
| 315 | 381 | return array(); |
| 316 | 382 | } |
| 317 | - $registry = desktop_mode_games_get_registered(); | |
| 383 | + $registry = openstation_games_get_registered(); | |
| 318 | 384 | if ( empty( $registry ) ) { |
| 319 | 385 | return array(); |
| 320 | 386 | } |
| 321 | 387 | $out = array(); |
| @@ -323,9 +389,9 @@ | ||
| 323 | 389 | if ( ! is_array( $entry ) || empty( $entry['id'] ) ) { |
| 324 | 390 | continue; |
| 325 | 391 | } |
| 326 | 392 | $handle = isset( $entry['script'] ) ? (string) $entry['script'] : ''; |
| 327 | - $payload = desktop_mode_resolve_script_payload( $handle ); | |
| 393 | + $payload = openstation_resolve_script_payload( $handle ); | |
| 328 | 394 | $out[] = array( |
| 329 | 395 | 'id' => (string) $entry['id'], |
| 330 | 396 | 'title' => isset( $entry['title'] ) ? (string) $entry['title'] : '', |
| 331 | 397 | 'description' => isset( $entry['description'] ) ? (string) $entry['description'] : '', |
| @@ -342,11 +408,19 @@ | ||
| 342 | 408 | $entry['score_columns'] |
| 343 | 409 | ) |
| 344 | 410 | : array(), |
| 345 | 411 | 'config' => array_merge( |
| 346 | - desktop_mode_games_framework_config(), | |
| 412 | + openstation_games_framework_config(), | |
| 347 | 413 | isset( $entry['config'] ) && is_array( $entry['config'] ) ? $entry['config'] : array() |
| 348 | 414 | ), |
| 415 | + // The window's size, known before its bundle is — so the | |
| 416 | + // shell can open the window (and start its loading spinner) | |
| 417 | + // on the click rather than after the download. Omitted | |
| 418 | + // entirely when the game declared none, which reads as | |
| 419 | + // "use the framework defaults" on the JS side. | |
| 420 | + 'window' => isset( $entry['window'] ) && is_array( $entry['window'] ) | |
| 421 | + ? $entry['window'] | |
| 422 | + : array(), | |
| 349 | 423 | 'scriptUrl' => $payload['url'], |
| 350 | 424 | 'scriptHandle' => $handle, |
| 351 | 425 | 'scriptBefore' => $payload['before'], |
| 352 | 426 | 'scriptAfter' => $payload['after'], |
| @@ -351,8 +425,11 @@ | ||
| 351 | 425 | 'scriptBefore' => $payload['before'], |
| 352 | 426 | 'scriptAfter' => $payload['after'], |
| 353 | 427 | 'scriptL10n' => $payload['l10n'], |
| 354 | 428 | 'scriptTranslations' => $payload['translations'], |
| 429 | + // The handle's dependency closure, replayed before the bundle | |
| 430 | + // on its lazy load — see `openstation_resolve_script_dependencies()`. | |
| 431 | + 'scriptDeps' => openstation_resolve_script_dependencies( $handle ), | |
| 355 | 432 | ); |
| 356 | 433 | } |
| 357 | 434 | return $out; |
| 358 | 435 | } |