PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / games / registry.php

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

376 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — 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.desktopModeGames[ <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 WPDesktopMode
20 * @since 0.9.6
21 */
22
23 defined( 'ABSPATH' ) || exit;
24
25 /**
26 * Register a server-side desktop game.
27 *
28 * Example:
29 *
30 * ```php
31 * desktop_mode_register_game( 'inkfall', array(
32 * 'title' => __( 'Inkfall', 'desktop-mode' ),
33 * 'description' => __( 'Type the falling words.', 'desktop-mode' ),
34 * 'icon_svg' => '<svg …>…</svg>',
35 * 'script' => 'desktop-mode-game-inkfall',
36 * 'score_columns' => array(
37 * array( 'key' => 'score', 'label' => __( 'Score', 'desktop-mode' ), 'type' => 'number' ),
38 * array( 'key' => 'time', 'label' => __( 'Time', 'desktop-mode' ), 'type' => 'time' ),
39 * ),
40 * 'config' => array( 'pace' => 'brisk' ),
41 * ) );
42 * ```
43 *
44 * ```js
45 * // Inside desktop-mode-game-inkfall.js
46 * window.desktopModeGames = window.desktopModeGames || {};
47 * window.desktopModeGames.inkfall = {
48 * id: 'inkfall',
49 * title: 'Inkfall',
50 * icon: 'data:image/svg+xml;base64,…',
51 * scoreColumns: [ … ],
52 * render: function ( ctx ) { return function () {}; },
53 * };
54 * ```
55 *
56 * @since 0.9.6
57 *
58 * @param string $id Game id (slug). Must match the
59 * `window.desktopModeGames[<id>]` key the game's
60 * JS publishes.
61 * @param array $args {
62 * @type string $title Launcher label. Required.
63 * @type string $description Plain-text description shown on the
64 * launcher tile. Optional.
65 * @type string $icon Dashicon class, http(s) URL, or
66 * `data:image/svg+xml` URI.
67 * @type string $icon_svg Raw SVG markup shorthand — converted
68 * to a base64 data URI. Wins over
69 * `icon`.
70 * @type string $script Registered script handle whose file
71 * publishes the game def. Required.
72 * @type array[] $score_columns Scoreboard column declarations:
73 * `{ key, label, type }` with type one
74 * of `number` | `time` | `text`.
75 * @type array $config Arbitrary blob shipped to the game's
76 * launch context (asset URLs, tuning).
77 * The framework merges its own keys in
78 * underneath (`wordsUrl` — see
79 * includes/games/config.php); the
80 * game's keys win on collision.
81 * @type string[] $capabilities Gate: ALL caps must match.
82 * }
83 * @return true|WP_Error `true` on success; `WP_Error` otherwise.
84 */
85 function desktop_mode_register_game( $id, $args = array() ) {
86 $id = sanitize_key( (string) $id );
87 if ( '' === $id ) {
88 return desktop_mode_registration_error(
89 'desktop_mode_missing_id',
90 __( 'Game id is required and must be a valid slug.', 'desktop-mode' )
91 );
92 }
93
94 $defaults = array(
95 'title' => '',
96 'description' => '',
97 'icon' => 'dashicons-admin-generic',
98 'icon_svg' => '',
99 'script' => '',
100 'score_columns' => array(),
101 'config' => array(),
102 'capabilities' => array(),
103 );
104 $args = wp_parse_args( $args, $defaults );
105
106 $svg = trim( (string) $args['icon_svg'] );
107 if ( '' !== $svg ) {
108 // Same defence-in-depth as desktop icons: the data URI is
109 // consumed via `<img src=…>` (which sandboxes SVG scripts),
110 // but reject script tags outright anyway.
111 if ( false !== stripos( $svg, '<script' ) ) {
112 return desktop_mode_registration_error(
113 'desktop_mode_invalid_icon_svg',
114 __( 'Game `icon_svg` must not contain a <script> tag.', 'desktop-mode' ),
115 array( 'id' => $id )
116 );
117 }
118 if ( 0 !== stripos( ltrim( $svg ), '<svg' ) ) {
119 return desktop_mode_registration_error(
120 'desktop_mode_invalid_icon_svg',
121 __( 'Game `icon_svg` must start with a <svg> root element.', 'desktop-mode' ),
122 array( 'id' => $id )
123 );
124 }
125 $args['icon'] = 'data:image/svg+xml;base64,' . base64_encode( $svg );
126 }
127
128 foreach ( (array) $args['capabilities'] as $cap ) {
129 if ( ! current_user_can( (string) $cap ) ) {
130 return desktop_mode_registration_error(
131 'desktop_mode_capability_denied',
132 sprintf(
133 /* translators: %s: capability slug. */
134 __( 'Current user lacks the %s capability required to register this game.', 'desktop-mode' ),
135 (string) $cap
136 ),
137 array( 'capability' => (string) $cap, 'id' => $id )
138 );
139 }
140 }
141
142 if ( '' === (string) $args['title'] ) {
143 return desktop_mode_registration_error(
144 'desktop_mode_missing_title',
145 __( 'Game registration requires a non-empty `title`.', 'desktop-mode' ),
146 array( 'id' => $id )
147 );
148 }
149 if ( '' === (string) $args['script'] ) {
150 return desktop_mode_registration_error(
151 'desktop_mode_missing_script',
152 __( 'Game registration requires a `script` handle that publishes the game def.', 'desktop-mode' ),
153 array( 'id' => $id )
154 );
155 }
156
157 $entry = array(
158 'id' => $id,
159 'title' => (string) $args['title'],
160 'description' => sanitize_textarea_field( (string) $args['description'] ),
161 'icon' => desktop_mode_sanitize_dock_icon( (string) $args['icon'] ),
162 'script' => (string) $args['script'],
163 'score_columns' => desktop_mode_games_sanitize_score_columns( $args['score_columns'] ),
164 'config' => is_array( $args['config'] ) ? $args['config'] : array(),
165 );
166 desktop_mode_games_registry( $id, $entry );
167
168 /**
169 * Fires after a desktop game is successfully registered.
170 *
171 * Does NOT fire when `desktop_mode_register_game()` returns a
172 * `WP_Error`.
173 *
174 * @since 0.9.6
175 *
176 * @param string $id The game id.
177 * @param array $entry The stored registry entry.
178 */
179 do_action( 'desktop_mode_game_registered', $id, $entry );
180
181 return true;
182 }
183
184 /**
185 * Normalize the `score_columns` declaration: drop rows without a
186 * valid key, default labels to the key, and clamp `type` to the
187 * supported set.
188 *
189 * @since 0.9.6
190 * @internal
191 *
192 * @param mixed $columns Raw caller input.
193 * @return array[] Sanitized `{ key, label, type }` rows.
194 */
195 function desktop_mode_games_sanitize_score_columns( $columns ) {
196 if ( ! is_array( $columns ) ) {
197 return array();
198 }
199 $out = array();
200 foreach ( $columns as $column ) {
201 if ( ! is_array( $column ) ) {
202 continue;
203 }
204 $key = sanitize_key( (string) ( $column['key'] ?? '' ) );
205 if ( '' === $key ) {
206 continue;
207 }
208 $label = sanitize_text_field( (string) ( $column['label'] ?? '' ) );
209 $type = (string) ( $column['type'] ?? 'number' );
210 if ( ! in_array( $type, array( 'number', 'time', 'text' ), true ) ) {
211 $type = 'number';
212 }
213 $out[] = array(
214 'key' => $key,
215 'label' => '' !== $label ? $label : $key,
216 'type' => $type,
217 );
218 }
219 return $out;
220 }
221
222 /**
223 * Internal module-level registry for games registered via
224 * {@see desktop_mode_register_game()}. Same static-store pattern as
225 * the widget + wallpaper + native-window registries.
226 *
227 * @since 0.9.6
228 * @internal
229 */
230 function desktop_mode_games_registry( $id = '', $entry = null ) {
231 static $store = array();
232
233 if ( '' === (string) $id ) {
234 return $store;
235 }
236 // Sentinel write: the literal string `__unset__` removes the entry.
237 if ( '__unset__' === $entry ) {
238 unset( $store[ $id ] );
239 return null;
240 }
241 if ( null !== $entry ) {
242 $store[ $id ] = $entry;
243 }
244 return isset( $store[ $id ] ) ? $store[ $id ] : null;
245 }
246
247 /**
248 * Unregister a game. Safe to call for unknown ids.
249 *
250 * @since 0.9.6
251 *
252 * @param string $id Game id.
253 * @return bool Whether an entry was removed.
254 */
255 function desktop_mode_unregister_game( $id ) {
256 $id = sanitize_key( (string) $id );
257 if ( '' === $id || null === desktop_mode_games_registry( $id ) ) {
258 return false;
259 }
260 desktop_mode_games_registry( $id, '__unset__' );
261 return true;
262 }
263
264 /**
265 * The registered game entries with the `desktop_mode_games` filter
266 * applied. This is the read path everything else (payload, REST
267 * validation) goes through, so filter-registered games validate.
268 *
269 * @since 0.9.6
270 *
271 * @return array[] Entries keyed by game id.
272 */
273 function desktop_mode_games_get_registered() {
274 $registry = desktop_mode_games_registry();
275
276 /**
277 * Filters the server-declared game list. Mirrors the JS-side
278 * `desktop-mode.games` filter so plugins can add, hide, or
279 * override entries at boot without round-tripping through the
280 * JS registry.
281 *
282 * @since 0.9.6
283 *
284 * @param array[] $registry The registered game entries, keyed by id.
285 */
286 $registry = apply_filters( 'desktop_mode_games', $registry );
287
288 return is_array( $registry ) ? $registry : array();
289 }
290
291 /**
292 * Whether a game id is known to the server registry (post-filter).
293 * REST routes 404 unknown games through this.
294 *
295 * @since 0.9.6
296 *
297 * @param string $id Game id.
298 * @return bool
299 */
300 function desktop_mode_games_is_registered( $id ) {
301 $id = sanitize_key( (string) $id );
302 if ( '' === $id ) {
303 return false;
304 }
305 $registry = desktop_mode_games_get_registered();
306 if ( isset( $registry[ $id ] ) ) {
307 return true;
308 }
309 // Filter authors may return a plain list instead of an id-keyed
310 // map — accept entries carrying the id in their payload too.
311 foreach ( $registry as $entry ) {
312 if ( is_array( $entry ) && isset( $entry['id'] ) && (string) $entry['id'] === $id ) {
313 return true;
314 }
315 }
316 return false;
317 }
318
319 /**
320 * Build the game list for the shell payload. Only metadata + the
321 * resolved script URL cross the wire; the game's render callback is
322 * announced via the JS global its (lazily loaded) script sets up.
323 *
324 * @since 0.9.6
325 *
326 * @return array[]
327 */
328 function desktop_mode_build_desktop_games_payload() {
329 // The module doesn't load when the framework is disabled, so this
330 // only guards a mid-request flip (the admin just saved the toggle).
331 if ( ! desktop_mode_games_enabled() ) {
332 return array();
333 }
334 $registry = desktop_mode_games_get_registered();
335 if ( empty( $registry ) ) {
336 return array();
337 }
338 $out = array();
339 foreach ( $registry as $entry ) {
340 if ( ! is_array( $entry ) || empty( $entry['id'] ) ) {
341 continue;
342 }
343 $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
344 $payload = desktop_mode_resolve_script_payload( $handle );
345 $out[] = array(
346 'id' => (string) $entry['id'],
347 'title' => isset( $entry['title'] ) ? (string) $entry['title'] : '',
348 'description' => isset( $entry['description'] ) ? (string) $entry['description'] : '',
349 'icon' => isset( $entry['icon'] ) ? (string) $entry['icon'] : '',
350 'scoreColumns' => isset( $entry['score_columns'] ) && is_array( $entry['score_columns'] )
351 ? array_map(
352 static function ( $column ) {
353 return array(
354 'key' => (string) $column['key'],
355 'label' => (string) $column['label'],
356 'type' => (string) $column['type'],
357 );
358 },
359 $entry['score_columns']
360 )
361 : array(),
362 'config' => array_merge(
363 desktop_mode_games_framework_config(),
364 isset( $entry['config'] ) && is_array( $entry['config'] ) ? $entry['config'] : array()
365 ),
366 'scriptUrl' => $payload['url'],
367 'scriptHandle' => $handle,
368 'scriptBefore' => $payload['before'],
369 'scriptAfter' => $payload['after'],
370 'scriptL10n' => $payload['l10n'],
371 'scriptTranslations' => $payload['translations'],
372 );
373 }
374 return $out;
375 }
376