PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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.8, at includes/games/registry.php

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