PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / games / registry.php

registry.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.8, at includes/games/registry.php

433 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Games registry.
4 *
5 * Server-side registration API + payload builder for desktop games.
6 * A game's discovery metadata (title, icon, description, score
7 * columns) is declared here in PHP so the Games window and the
8 * scoreboard tabs paint at shell boot without downloading any game
9 * code; the game's JS bundle — declared via the `script` handle —
10 * is loaded lazily on first launch and publishes the full def
11 * (including the `render` callback) on
12 * `window.openStationGames[ <id> ]`.
13 *
14 * This deliberate laziness is the one way the games registry differs
15 * from the wallpaper registry it is otherwise modeled on: wallpaper
16 * scripts are enqueued eagerly because the active wallpaper must
17 * paint at boot; game code is only needed when someone plays.
18 *
19 * @package OpenStation
20 */
21
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Register a server-side desktop game.
26 *
27 * Example:
28 *
29 * ```php
30 * openstation_register_game( 'inkfall', array(
31 * 'title' => __( 'Inkfall', 'desktop-mode' ),
32 * 'description' => __( 'Type the falling words.', 'desktop-mode' ),
33 * 'icon_svg' => '<svg …>…</svg>',
34 * 'script' => 'os-game-inkfall',
35 * 'score_columns' => array(
36 * array( 'key' => 'score', 'label' => __( 'Score', 'desktop-mode' ), 'type' => 'number' ),
37 * array( 'key' => 'time', 'label' => __( 'Time', 'desktop-mode' ), 'type' => 'time' ),
38 * ),
39 * 'config' => array( 'pace' => 'brisk' ),
40 * ) );
41 * ```
42 *
43 * ```js
44 * // Inside os-game-inkfall.js
45 * window.openStationGames = window.openStationGames || {};
46 * window.openStationGames.inkfall = {
47 * id: 'inkfall',
48 * title: 'Inkfall',
49 * icon: 'data:image/svg+xml;base64,…',
50 * scoreColumns: [ … ],
51 * render: function ( ctx ) { return function () {}; },
52 * };
53 * ```
54 *
55 * @param string $id Game id (slug). Must match the
56 * `window.openStationGames[<id>]` key the game's
57 * JS publishes.
58 * @param array $args {
59 * @type string $title Launcher label. Required.
60 * @type string $description Plain-text description shown on the
61 * launcher tile. Optional.
62 * @type string $icon Dashicon class, http(s) URL, or
63 * `data:image/svg+xml` URI.
64 * @type string $icon_svg Raw SVG markup shorthand — converted
65 * to a base64 data URI. Wins over
66 * `icon`.
67 * @type string $script Registered script handle whose file
68 * publishes the game def. Required.
69 * @type array[] $score_columns Scoreboard column declarations:
70 * `{ key, label, type }` with type one
71 * of `number` | `time` | `text`.
72 * @type array $config Arbitrary blob shipped to the game's
73 * launch context (asset URLs, tuning).
74 * The framework merges its own keys in
75 * underneath (`wordsUrl` — see
76 * includes/games/config.php); the
77 * game's keys win on collision.
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.
92 * }
93 * @return true|WP_Error `true` on success; `WP_Error` otherwise.
94 */
95 function openstation_register_game( $id, $args = array() ) {
96 $id = sanitize_key( (string) $id );
97 if ( '' === $id ) {
98 return openstation_registration_error(
99 'openstation_missing_id',
100 __( 'Game id is required and must be a valid slug.', 'desktop-mode' )
101 );
102 }
103
104 $defaults = array(
105 'title' => '',
106 'description' => '',
107 'icon' => 'dashicons-admin-generic',
108 'icon_svg' => '',
109 'script' => '',
110 'score_columns' => array(),
111 'config' => array(),
112 'capabilities' => array(),
113 'window' => array(),
114 );
115 $args = wp_parse_args( $args, $defaults );
116
117 $svg = trim( (string) $args['icon_svg'] );
118 if ( '' !== $svg ) {
119 // Same defence-in-depth as desktop icons: the data URI is
120 // consumed via `<img src=…>` (which sandboxes SVG scripts),
121 // but reject script tags outright anyway.
122 if ( false !== stripos( $svg, '<script' ) ) {
123 return openstation_registration_error(
124 'openstation_invalid_icon_svg',
125 __( 'Game `icon_svg` must not contain a <script> tag.', 'desktop-mode' ),
126 array( 'id' => $id )
127 );
128 }
129 if ( 0 !== stripos( ltrim( $svg ), '<svg' ) ) {
130 return openstation_registration_error(
131 'openstation_invalid_icon_svg',
132 __( 'Game `icon_svg` must start with a <svg> root element.', 'desktop-mode' ),
133 array( 'id' => $id )
134 );
135 }
136 $args['icon'] = 'data:image/svg+xml;base64,' . base64_encode( $svg );
137 }
138
139 foreach ( (array) $args['capabilities'] as $cap ) {
140 if ( ! current_user_can( (string) $cap ) ) {
141 return openstation_registration_error(
142 'openstation_capability_denied',
143 sprintf(
144 /* translators: %s: capability slug. */
145 __( 'Current user lacks the %s capability required to register this game.', 'desktop-mode' ),
146 (string) $cap
147 ),
148 array(
149 'capability' => (string) $cap,
150 'id' => $id,
151 )
152 );
153 }
154 }
155
156 if ( '' === (string) $args['title'] ) {
157 return openstation_registration_error(
158 'openstation_missing_title',
159 __( 'Game registration requires a non-empty `title`.', 'desktop-mode' ),
160 array( 'id' => $id )
161 );
162 }
163 if ( '' === (string) $args['script'] ) {
164 return openstation_registration_error(
165 'openstation_missing_script',
166 __( 'Game registration requires a `script` handle that publishes the game def.', 'desktop-mode' ),
167 array( 'id' => $id )
168 );
169 }
170
171 $entry = array(
172 'id' => $id,
173 'title' => (string) $args['title'],
174 'description' => sanitize_textarea_field( (string) $args['description'] ),
175 'icon' => openstation_sanitize_dock_icon( (string) $args['icon'] ),
176 'script' => (string) $args['script'],
177 'score_columns' => openstation_games_sanitize_score_columns( $args['score_columns'] ),
178 'config' => is_array( $args['config'] ) ? $args['config'] : array(),
179 'window' => openstation_games_sanitize_window( $args['window'] ),
180 );
181 openstation_games_registry( $id, $entry );
182
183 /**
184 * Fires after a desktop game is successfully registered.
185 *
186 * Does NOT fire when `openstation_register_game()` returns a
187 * `WP_Error`.
188 *
189 * @param string $id The game id.
190 * @param array $entry The stored registry entry.
191 */
192 do_action( 'openstation_game_registered', $id, $entry );
193
194 return true;
195 }
196
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 /**
246 * Normalize the `score_columns` declaration: drop rows without a
247 * valid key, default labels to the key, and clamp `type` to the
248 * supported set.
249 *
250 * @internal
251 *
252 * @param mixed $columns Raw caller input.
253 * @return array[] Sanitized `{ key, label, type }` rows.
254 */
255 function openstation_games_sanitize_score_columns( $columns ) {
256 if ( ! is_array( $columns ) ) {
257 return array();
258 }
259 $out = array();
260 foreach ( $columns as $column ) {
261 if ( ! is_array( $column ) ) {
262 continue;
263 }
264 $key = sanitize_key( (string) ( $column['key'] ?? '' ) );
265 if ( '' === $key ) {
266 continue;
267 }
268 $label = sanitize_text_field( (string) ( $column['label'] ?? '' ) );
269 $type = (string) ( $column['type'] ?? 'number' );
270 if ( ! in_array( $type, array( 'number', 'time', 'text' ), true ) ) {
271 $type = 'number';
272 }
273 $out[] = array(
274 'key' => $key,
275 'label' => '' !== $label ? $label : $key,
276 'type' => $type,
277 );
278 }
279 return $out;
280 }
281
282 /**
283 * Internal module-level registry for games registered via
284 * {@see openstation_register_game()}. Same static-store pattern as
285 * the widget + wallpaper + native-window registries.
286 *
287 * @internal
288 */
289 function openstation_games_registry( $id = '', $entry = null ) {
290 static $store = array();
291
292 if ( '' === (string) $id ) {
293 return $store;
294 }
295 // Sentinel write: the literal string `__unset__` removes the entry.
296 if ( '__unset__' === $entry ) {
297 unset( $store[ $id ] );
298 return null;
299 }
300 if ( null !== $entry ) {
301 $store[ $id ] = $entry;
302 }
303 return isset( $store[ $id ] ) ? $store[ $id ] : null;
304 }
305
306 /**
307 * Unregister a game. Safe to call for unknown ids.
308 *
309 * @param string $id Game id.
310 * @return bool Whether an entry was removed.
311 */
312 function openstation_unregister_game( $id ) {
313 $id = sanitize_key( (string) $id );
314 if ( '' === $id || null === openstation_games_registry( $id ) ) {
315 return false;
316 }
317 openstation_games_registry( $id, '__unset__' );
318 return true;
319 }
320
321 /**
322 * The registered game entries with the `openstation_games` filter
323 * applied. This is the read path everything else (payload, REST
324 * validation) goes through, so filter-registered games validate.
325 *
326 * @return array[] Entries keyed by game id.
327 */
328 function openstation_games_get_registered() {
329 $registry = openstation_games_registry();
330
331 /**
332 * Filters the server-declared game list. Mirrors the JS-side
333 * `os.games` filter so plugins can add, hide, or
334 * override entries at boot without round-tripping through the
335 * JS registry.
336 *
337 * @param array[] $registry The registered game entries, keyed by id.
338 */
339 $registry = apply_filters( 'openstation_games', $registry );
340
341 return is_array( $registry ) ? $registry : array();
342 }
343
344 /**
345 * Whether a game id is known to the server registry (post-filter).
346 * REST routes 404 unknown games through this.
347 *
348 * @param string $id Game id.
349 * @return bool
350 */
351 function openstation_games_is_registered( $id ) {
352 $id = sanitize_key( (string) $id );
353 if ( '' === $id ) {
354 return false;
355 }
356 $registry = openstation_games_get_registered();
357 if ( isset( $registry[ $id ] ) ) {
358 return true;
359 }
360 // Filter authors may return a plain list instead of an id-keyed
361 // map — accept entries carrying the id in their payload too.
362 foreach ( $registry as $entry ) {
363 if ( is_array( $entry ) && isset( $entry['id'] ) && (string) $entry['id'] === $id ) {
364 return true;
365 }
366 }
367 return false;
368 }
369
370 /**
371 * Build the game list for the shell payload. Only metadata + the
372 * resolved script URL cross the wire; the game's render callback is
373 * announced via the JS global its (lazily loaded) script sets up.
374 *
375 * @return array[]
376 */
377 function openstation_build_desktop_games_payload() {
378 // The module doesn't load when the framework is disabled, so this
379 // only guards a mid-request flip (the admin just saved the toggle).
380 if ( ! openstation_games_enabled() ) {
381 return array();
382 }
383 $registry = openstation_games_get_registered();
384 if ( empty( $registry ) ) {
385 return array();
386 }
387 $out = array();
388 foreach ( $registry as $entry ) {
389 if ( ! is_array( $entry ) || empty( $entry['id'] ) ) {
390 continue;
391 }
392 $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
393 $payload = openstation_resolve_script_payload( $handle );
394 $out[] = array(
395 'id' => (string) $entry['id'],
396 'title' => isset( $entry['title'] ) ? (string) $entry['title'] : '',
397 'description' => isset( $entry['description'] ) ? (string) $entry['description'] : '',
398 'icon' => isset( $entry['icon'] ) ? (string) $entry['icon'] : '',
399 'scoreColumns' => isset( $entry['score_columns'] ) && is_array( $entry['score_columns'] )
400 ? array_map(
401 static function ( $column ) {
402 return array(
403 'key' => (string) $column['key'],
404 'label' => (string) $column['label'],
405 'type' => (string) $column['type'],
406 );
407 },
408 $entry['score_columns']
409 )
410 : array(),
411 'config' => array_merge(
412 openstation_games_framework_config(),
413 isset( $entry['config'] ) && is_array( $entry['config'] ) ? $entry['config'] : array()
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(),
423 'scriptUrl' => $payload['url'],
424 'scriptHandle' => $handle,
425 'scriptBefore' => $payload['before'],
426 'scriptAfter' => $payload['after'],
427 'scriptL10n' => $payload['l10n'],
428 'scriptTranslations' => $payload['translations'],
429 );
430 }
431 return $out;
432 }
433