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

362 lines 11.7 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 * }
80 * @return true|WP_Error `true` on success; `WP_Error` otherwise.
81 */
82 function openstation_register_game( $id, $args = array() ) {
83 $id = sanitize_key( (string) $id );
84 if ( '' === $id ) {
85 return openstation_registration_error(
86 'openstation_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 openstation_registration_error(
110 'openstation_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 openstation_registration_error(
117 'openstation_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 openstation_registration_error(
128 'openstation_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(
135 'capability' => (string) $cap,
136 'id' => $id,
137 )
138 );
139 }
140 }
141
142 if ( '' === (string) $args['title'] ) {
143 return openstation_registration_error(
144 'openstation_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 openstation_registration_error(
151 'openstation_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' => openstation_sanitize_dock_icon( (string) $args['icon'] ),
162 'script' => (string) $args['script'],
163 'score_columns' => openstation_games_sanitize_score_columns( $args['score_columns'] ),
164 'config' => is_array( $args['config'] ) ? $args['config'] : array(),
165 );
166 openstation_games_registry( $id, $entry );
167
168 /**
169 * Fires after a desktop game is successfully registered.
170 *
171 * Does NOT fire when `openstation_register_game()` returns a
172 * `WP_Error`.
173 *
174 * @param string $id The game id.
175 * @param array $entry The stored registry entry.
176 */
177 do_action( 'openstation_game_registered', $id, $entry );
178
179 return true;
180 }
181
182 /**
183 * Normalize the `score_columns` declaration: drop rows without a
184 * valid key, default labels to the key, and clamp `type` to the
185 * supported set.
186 *
187 * @internal
188 *
189 * @param mixed $columns Raw caller input.
190 * @return array[] Sanitized `{ key, label, type }` rows.
191 */
192 function openstation_games_sanitize_score_columns( $columns ) {
193 if ( ! is_array( $columns ) ) {
194 return array();
195 }
196 $out = array();
197 foreach ( $columns as $column ) {
198 if ( ! is_array( $column ) ) {
199 continue;
200 }
201 $key = sanitize_key( (string) ( $column['key'] ?? '' ) );
202 if ( '' === $key ) {
203 continue;
204 }
205 $label = sanitize_text_field( (string) ( $column['label'] ?? '' ) );
206 $type = (string) ( $column['type'] ?? 'number' );
207 if ( ! in_array( $type, array( 'number', 'time', 'text' ), true ) ) {
208 $type = 'number';
209 }
210 $out[] = array(
211 'key' => $key,
212 'label' => '' !== $label ? $label : $key,
213 'type' => $type,
214 );
215 }
216 return $out;
217 }
218
219 /**
220 * Internal module-level registry for games registered via
221 * {@see openstation_register_game()}. Same static-store pattern as
222 * the widget + wallpaper + native-window registries.
223 *
224 * @internal
225 */
226 function openstation_games_registry( $id = '', $entry = null ) {
227 static $store = array();
228
229 if ( '' === (string) $id ) {
230 return $store;
231 }
232 // Sentinel write: the literal string `__unset__` removes the entry.
233 if ( '__unset__' === $entry ) {
234 unset( $store[ $id ] );
235 return null;
236 }
237 if ( null !== $entry ) {
238 $store[ $id ] = $entry;
239 }
240 return isset( $store[ $id ] ) ? $store[ $id ] : null;
241 }
242
243 /**
244 * Unregister a game. Safe to call for unknown ids.
245 *
246 * @param string $id Game id.
247 * @return bool Whether an entry was removed.
248 */
249 function openstation_unregister_game( $id ) {
250 $id = sanitize_key( (string) $id );
251 if ( '' === $id || null === openstation_games_registry( $id ) ) {
252 return false;
253 }
254 openstation_games_registry( $id, '__unset__' );
255 return true;
256 }
257
258 /**
259 * The registered game entries with the `openstation_games` filter
260 * applied. This is the read path everything else (payload, REST
261 * validation) goes through, so filter-registered games validate.
262 *
263 * @return array[] Entries keyed by game id.
264 */
265 function openstation_games_get_registered() {
266 $registry = openstation_games_registry();
267
268 /**
269 * Filters the server-declared game list. Mirrors the JS-side
270 * `os.games` filter so plugins can add, hide, or
271 * override entries at boot without round-tripping through the
272 * JS registry.
273 *
274 * @param array[] $registry The registered game entries, keyed by id.
275 */
276 $registry = apply_filters( 'openstation_games', $registry );
277
278 return is_array( $registry ) ? $registry : array();
279 }
280
281 /**
282 * Whether a game id is known to the server registry (post-filter).
283 * REST routes 404 unknown games through this.
284 *
285 * @param string $id Game id.
286 * @return bool
287 */
288 function openstation_games_is_registered( $id ) {
289 $id = sanitize_key( (string) $id );
290 if ( '' === $id ) {
291 return false;
292 }
293 $registry = openstation_games_get_registered();
294 if ( isset( $registry[ $id ] ) ) {
295 return true;
296 }
297 // Filter authors may return a plain list instead of an id-keyed
298 // map — accept entries carrying the id in their payload too.
299 foreach ( $registry as $entry ) {
300 if ( is_array( $entry ) && isset( $entry['id'] ) && (string) $entry['id'] === $id ) {
301 return true;
302 }
303 }
304 return false;
305 }
306
307 /**
308 * Build the game list for the shell payload. Only metadata + the
309 * resolved script URL cross the wire; the game's render callback is
310 * announced via the JS global its (lazily loaded) script sets up.
311 *
312 * @return array[]
313 */
314 function openstation_build_desktop_games_payload() {
315 // The module doesn't load when the framework is disabled, so this
316 // only guards a mid-request flip (the admin just saved the toggle).
317 if ( ! openstation_games_enabled() ) {
318 return array();
319 }
320 $registry = openstation_games_get_registered();
321 if ( empty( $registry ) ) {
322 return array();
323 }
324 $out = array();
325 foreach ( $registry as $entry ) {
326 if ( ! is_array( $entry ) || empty( $entry['id'] ) ) {
327 continue;
328 }
329 $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
330 $payload = openstation_resolve_script_payload( $handle );
331 $out[] = array(
332 'id' => (string) $entry['id'],
333 'title' => isset( $entry['title'] ) ? (string) $entry['title'] : '',
334 'description' => isset( $entry['description'] ) ? (string) $entry['description'] : '',
335 'icon' => isset( $entry['icon'] ) ? (string) $entry['icon'] : '',
336 'scoreColumns' => isset( $entry['score_columns'] ) && is_array( $entry['score_columns'] )
337 ? array_map(
338 static function ( $column ) {
339 return array(
340 'key' => (string) $column['key'],
341 'label' => (string) $column['label'],
342 'type' => (string) $column['type'],
343 );
344 },
345 $entry['score_columns']
346 )
347 : array(),
348 'config' => array_merge(
349 openstation_games_framework_config(),
350 isset( $entry['config'] ) && is_array( $entry['config'] ) ? $entry['config'] : array()
351 ),
352 'scriptUrl' => $payload['url'],
353 'scriptHandle' => $handle,
354 'scriptBefore' => $payload['before'],
355 'scriptAfter' => $payload['after'],
356 'scriptL10n' => $payload['l10n'],
357 'scriptTranslations' => $payload['translations'],
358 );
359 }
360 return $out;
361 }
362