PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 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 All 36 releases
desktop-mode / includes / registries / icons.php

icons.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.12, at includes/registries/icons.php

366 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Desktop-icons registry.
4 *
5 * Owns the registration API + payload builder for the wallpaper-
6 * surface app icons (`wp.os.icons`). The dock has its own
7 * rail; this is the second surface — clickable shortcuts that
8 * sit on the desktop wallpaper itself, registered via
9 * `openstation_register_icon()` and rendered by
10 * `src/desktop-icons.ts`.
11 *
12 * Not to be confused with `includes/wp-icon-registry.php`, which hands our
13 * eleven pieces of artwork to WordPress's own icon registry for use with
14 * `wp_get_icon()`. This file is about a surface of the desktop.
15 *
16 * Extracted from the 2,101-LOC `components.php` during the
17 * architecture-0.8.1 PHP slicing (phase 6). Behaviour is
18 * unchanged: every function name, every WP filter, every error
19 * code is identical. PHP looks function references up by name at
20 * hook-fire time, so existing call sites resolve from any module.
21 *
22 * @package OpenStation
23 */
24
25 defined( 'ABSPATH' ) || exit;
26
27
28 /**
29 * Register a desktop icon — a clickable shortcut tile that sits on
30 * the wallpaper (think macOS desktop icons / phone home screen apps)
31 * and opens a native window or a URL on click.
32 *
33 * Icons are distinct from dock tiles: the dock rail is for
34 * top-level admin pages, the desktop surface is for quick shortcuts
35 * a user or theme wants front-and-centre. A single plugin can
36 * register a window AND an icon that opens it — the two surfaces
37 * are orthogonal.
38 *
39 * Example — the classic Jorvy recipe:
40 *
41 * ```php
42 * openstation_register_window( 'jorvy', array( …window args… ) );
43 * openstation_register_icon( 'jorvy', array(
44 * 'title' => __( 'Jorvy', 'jorvy' ),
45 * 'icon' => 'dashicons-star-filled',
46 * 'window' => 'jorvy',
47 * 'position' => 10,
48 * ) );
49 * ```
50 *
51 * @param string $id Icon id. Must be a kebab-case-ish slug.
52 * @param array $args {
53 * Icon registration options.
54 *
55 * @type string $title Display label shown under the icon. Required.
56 * @type string $icon Dashicons class (`dashicons-*`), http(s)
57 * URL to an image, or `data:image/svg+xml`
58 * URI (`;base64,` or URL-encoded). Runs
59 * through the same sanitizer as dock icons
60 * — `javascript:` and other `data:` schemes
61 * are rejected. Required unless `icon_svg`
62 * is provided.
63 * @type string $icon_svg Raw SVG markup (e.g. `'<svg …>…</svg>'`).
64 * Convenience shorthand: the framework
65 * base64-encodes it into a `data:image/svg+xml;base64,…`
66 * URI and routes the result through the
67 * same sanitizer as `icon`. Wins over
68 * `icon` when both are supplied. Markup
69 * containing a `<script>` tag is rejected
70 * with `openstation_invalid_icon_svg`.
71 * @type string $window Id of a registered native window to
72 * open on click. Mutually exclusive
73 * with `url`.
74 * @type string $url URL to open on click (admin URLs
75 * open as a new window in the shell;
76 * off-site URLs open in a new tab).
77 * Mutually exclusive with `window`.
78 * @type int $position Sort order; lower renders earlier
79 * (top-left on the grid). Default 100.
80 * @type bool $pinned When `true`, the icon renders before
81 * any unpinned icon regardless of
82 * `position`, and is never user-
83 * draggable. Reserved for built-in
84 * shortcuts like "My WordPress".
85 * Default `false`.
86 * @type string[] $capabilities Gate: ALL caps must match. Any
87 * missed cap returns
88 * `WP_Error openstation_capability_denied`.
89 * }
90 * @return true|WP_Error `true` on success; `WP_Error` otherwise.
91 */
92 function openstation_register_icon( $id, $args = array() ) {
93 $id = sanitize_key( (string) $id );
94 if ( '' === $id ) {
95 return openstation_registration_error(
96 'openstation_missing_id',
97 __( 'Desktop icon id is required and must be a valid slug.', 'desktop-mode' )
98 );
99 }
100
101 $defaults = array(
102 'title' => '',
103 'icon' => 'dashicons-admin-generic',
104 'icon_svg' => '',
105 'window' => '',
106 'url' => '',
107 'position' => 100,
108 'pinned' => false,
109 'capabilities' => array(),
110 );
111 $args = wp_parse_args( $args, $defaults );
112
113 $svg = trim( (string) $args['icon_svg'] );
114 if ( '' !== $svg ) {
115 // Reject markup that contains a script tag outright. The data
116 // URI is consumed via `<img src=…>` in the browser (which
117 // sandboxes scripts inside SVG), but defence-in-depth catches
118 // callers who paste an SVG harvested from an untrusted source.
119 if ( false !== stripos( $svg, '<script' ) ) {
120 return openstation_registration_error(
121 'openstation_invalid_icon_svg',
122 __( 'Desktop icon `icon_svg` must not contain a <script> tag.', 'desktop-mode' ),
123 array( 'id' => $id )
124 );
125 }
126 if ( 0 !== stripos( ltrim( $svg ), '<svg' ) ) {
127 return openstation_registration_error(
128 'openstation_invalid_icon_svg',
129 __( 'Desktop icon `icon_svg` must start with a <svg> root element.', 'desktop-mode' ),
130 array( 'id' => $id )
131 );
132 }
133 $args['icon'] = 'data:image/svg+xml;base64,' . base64_encode( $svg );
134 }
135
136 foreach ( (array) $args['capabilities'] as $cap ) {
137 if ( ! current_user_can( (string) $cap ) ) {
138 return openstation_registration_error(
139 'openstation_capability_denied',
140 sprintf(
141 /* translators: %s: capability slug. */
142 __( 'Current user lacks the %s capability required to register this desktop icon.', 'desktop-mode' ),
143 (string) $cap
144 ),
145 array(
146 'capability' => (string) $cap,
147 'id' => $id,
148 )
149 );
150 }
151 }
152
153 if ( '' === (string) $args['title'] ) {
154 return openstation_registration_error(
155 'openstation_missing_title',
156 __( 'Desktop icon registration requires a non-empty `title`.', 'desktop-mode' ),
157 array( 'id' => $id )
158 );
159 }
160
161 $window = sanitize_key( (string) $args['window'] );
162 $url = (string) $args['url'];
163 if ( '' !== $window && '' !== $url ) {
164 return openstation_registration_error(
165 'openstation_conflicting_target',
166 __( 'Desktop icon cannot declare both `window` and `url`; pick one target.', 'desktop-mode' ),
167 array( 'id' => $id )
168 );
169 }
170 if ( '' === $window && '' === $url ) {
171 return openstation_registration_error(
172 'openstation_missing_target',
173 __( 'Desktop icon must declare a `window` id or a `url` target.', 'desktop-mode' ),
174 array( 'id' => $id )
175 );
176 }
177 if ( '' !== $url ) {
178 // Accept any http(s) URL. Same-origin admin URLs open in a
179 // desktop window; off-site URLs open in a new browser tab at
180 // click time (shell decides).
181 $url = esc_url_raw( $url, array( 'http', 'https' ) );
182 if ( '' === $url ) {
183 return openstation_registration_error(
184 'openstation_invalid_url',
185 __( 'Desktop icon `url` must be a valid http(s) URL.', 'desktop-mode' ),
186 array( 'id' => $id )
187 );
188 }
189 }
190
191 $entry = array(
192 'id' => $id,
193 'title' => (string) $args['title'],
194 'icon' => openstation_sanitize_dock_icon( (string) $args['icon'] ),
195 'window' => $window,
196 'url' => $url,
197 'position' => (int) $args['position'],
198 'pinned' => (bool) $args['pinned'],
199 );
200 openstation_desktop_icon_registry( $id, $entry );
201
202 /**
203 * Fires after a desktop icon is successfully registered.
204 *
205 * Does NOT fire when `openstation_register_icon()` returns a
206 * `WP_Error`.
207 *
208 * @param string $id The icon id.
209 * @param array $entry The stored registry entry (id, title,
210 * icon, window, url, position, pinned).
211 */
212 do_action( 'openstation_icon_registered', $id, $entry );
213
214 return true;
215 }
216
217 /**
218 * Internal module-level registry for desktop icons registered via
219 * {@see openstation_register_icon()}. Same static-store pattern as
220 * the widget + native-window + wallpaper registries.
221 *
222 * @internal
223 */
224 function openstation_desktop_icon_registry( $id = '', $entry = null ) {
225 static $store = array();
226
227 if ( '' === (string) $id ) {
228 return $store;
229 }
230 // Sentinel write: passing the literal string `__unset__` removes
231 // the entry. Used by `openstation_unregister_icon()` and by
232 // PHPUnit teardowns; lets us clear test-only registrations
233 // without exposing the static `$store` directly.
234 if ( '__unset__' === $entry ) {
235 unset( $store[ $id ] );
236 return null;
237 }
238 if ( null !== $entry ) {
239 $store[ $id ] = $entry;
240 }
241 return isset( $store[ $id ] ) ? $store[ $id ] : null;
242 }
243
244 /**
245 * Remove a previously registered desktop icon from the static
246 * registry. Mirror of `openstation_register_icon()` — handy for
247 * plugins that register icons conditionally and need to drop them
248 * mid-request, and for PHPUnit teardowns that shouldn't leak
249 * registrations into other tests.
250 *
251 * @param string $id Icon id passed to `openstation_register_icon()`.
252 * @return void
253 */
254 function openstation_unregister_icon( $id ) {
255 $id = sanitize_key( (string) $id );
256 if ( '' === $id ) {
257 return;
258 }
259 openstation_desktop_icon_registry( $id, '__unset__' );
260 }
261
262 /**
263 * One desktop icon entry, as the shell sees it: after the `openstation_icons`
264 * filter, not from the raw registry.
265 *
266 * The payload and the orphan placement both run the filter, so an entry a
267 * plugin injects there gets a placement row minted for it; the shortcut that
268 * row points at has to resolve through the same filter or it serializes as
269 * a missing file and never paints. The same holds the other way: an entry
270 * the filter removes must not keep resolving from the raw store.
271 *
272 * Deliberately uncached here: registrations can change within a request
273 * (tests do it constantly). Each call runs the whole filter chain, so a
274 * caller that asks about one icon several times keeps the answer, as
275 * `OpenStation_Shortcut_File` does for the life of one placement.
276 *
277 * @param string $id Icon id.
278 * @return array|null The entry, or null when no icon has that id.
279 */
280 function openstation_desktop_icon_entry( $id ) {
281 $id = (string) $id;
282 if ( '' === $id ) {
283 return null;
284 }
285 /** This filter is documented in openstation_build_desktop_icons_payload(). */
286 $registry = apply_filters( 'openstation_icons', openstation_desktop_icon_registry() );
287 if ( ! is_array( $registry ) ) {
288 return null;
289 }
290 if ( isset( $registry[ $id ] ) && is_array( $registry[ $id ] ) ) {
291 return $registry[ $id ];
292 }
293 // A filter may hand back a re-indexed list rather than an id map.
294 foreach ( $registry as $entry ) {
295 if ( is_array( $entry ) && isset( $entry['id'] ) && $id === (string) $entry['id'] ) {
296 return $entry;
297 }
298 }
299 return null;
300 }
301
302 /**
303 * Build the desktop-icon list for the shell payload. Applies a
304 * `openstation_icons` filter so plugins can hide / reorder / rename
305 * entries registered by others — mirrors the wallpaper payload
306 * builder's filter discipline.
307 *
308 * @return array[]
309 */
310 function openstation_build_desktop_icons_payload() {
311 $registry = openstation_desktop_icon_registry();
312 if ( ! is_array( $registry ) ) {
313 $registry = array();
314 }
315
316 /**
317 * Filters the full desktop-icon registry before it ships to the
318 * shell. Each entry is the shape stored by
319 * `openstation_register_icon()` (`id`, `title`, `icon`, `window`,
320 * `url`, `position`, `pinned`). Return a reordered / filtered array.
321 *
322 * @param array[] $registry The registered icon entries.
323 */
324 $registry = apply_filters( 'openstation_icons', $registry );
325 // Checked after the filter, not before: an empty registry is exactly
326 // where a filter-injected entry is the only icon, and returning early
327 // dropped it.
328 if ( ! is_array( $registry ) || empty( $registry ) ) {
329 return array();
330 }
331
332 $out = array();
333 foreach ( $registry as $entry ) {
334 if ( ! is_array( $entry ) || empty( $entry['id'] ) ) {
335 continue;
336 }
337 $out[] = array(
338 'id' => (string) $entry['id'],
339 'title' => isset( $entry['title'] ) ? (string) $entry['title'] : '',
340 'icon' => isset( $entry['icon'] ) ? (string) $entry['icon'] : 'dashicons-admin-generic',
341 'window' => isset( $entry['window'] ) ? (string) $entry['window'] : '',
342 'url' => isset( $entry['url'] ) ? (string) $entry['url'] : '',
343 'position' => isset( $entry['position'] ) ? (int) $entry['position'] : 100,
344 'pinned' => ! empty( $entry['pinned'] ),
345 );
346 }
347
348 // Pinned icons first; then by position. Ties break on insertion order.
349 usort(
350 $out,
351 static function ( $a, $b ) {
352 $ap = ! empty( $a['pinned'] ) ? 0 : 1;
353 $bp = ! empty( $b['pinned'] ) ? 0 : 1;
354 if ( $ap !== $bp ) {
355 return $ap - $bp;
356 }
357 if ( $a['position'] === $b['position'] ) {
358 return 0;
359 }
360 return $a['position'] < $b['position'] ? -1 : 1;
361 }
362 );
363
364 return $out;
365 }
366