PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
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 / plugins-window / rest-fields.php

rest-fields.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.4, at includes/plugins-window/rest-fields.php

889 lines 32.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Native Plugins Window: REST field decorators.
4 *
5 * Adds enrichment fields to Core's `/wp/v2/plugins` REST resource so
6 * the JS bundle can render rich rows in one round-trip:
7 *
8 * - openstation_update_available — `{ available, new_version }`
9 * - openstation_can_manage — `{ activate, deactivate, delete }`
10 * - openstation_wporg_slug — .org directory slug, or null when not listed
11 * - openstation_icon_url — local-folder icon, falling back to wp.org
12 * - openstation_size_kb — disk size of plugin folder
13 * - openstation_auto_update — `{ enabled, forced, supported }`
14 *
15 * Plugin Check posture: every callback below uses ONLY functions
16 * available in `wp-includes/` (current_user_can, get_site_transient,
17 * filesize, glob, …). No admin-only includes are needed, so REST is
18 * the right home — registering these fields on `rest_api_init` keeps
19 * the contract consistent with Core's other plugin REST decorators.
20 *
21 * @package OpenStation
22 */
23
24 defined( 'ABSPATH' ) || exit;
25
26 /**
27 * Register the six enrichment fields on the `plugin` REST resource.
28 */
29 function openstation_plugins_window_register_rest_fields() {
30 register_rest_field(
31 'plugin',
32 'openstation_update_available',
33 array(
34 'get_callback' => 'openstation_plugins_window_field_update_available',
35 'schema' => array(
36 'description' => __( 'Whether an update is available for this plugin (and the available version).', 'desktop-mode' ),
37 'type' => 'object',
38 'context' => array( 'view', 'edit' ),
39 'readonly' => true,
40 ),
41 )
42 );
43
44 register_rest_field(
45 'plugin',
46 'openstation_can_manage',
47 array(
48 'get_callback' => 'openstation_plugins_window_field_can_manage',
49 'schema' => array(
50 'description' => __( 'Per-plugin capability flags for the requester (activate / deactivate / delete).', 'desktop-mode' ),
51 'type' => 'object',
52 'context' => array( 'view', 'edit' ),
53 'readonly' => true,
54 ),
55 )
56 );
57
58 register_rest_field(
59 'plugin',
60 'openstation_wporg_slug',
61 array(
62 'get_callback' => 'openstation_plugins_window_field_wporg_slug',
63 'schema' => array(
64 'description' => __( 'The plugin\'s slug on the WordPress.org directory, or null when the plugin is not listed there.', 'desktop-mode' ),
65 'type' => array( 'string', 'null' ),
66 'context' => array( 'view', 'edit' ),
67 'readonly' => true,
68 ),
69 )
70 );
71
72 register_rest_field(
73 'plugin',
74 'openstation_icon_url',
75 array(
76 'get_callback' => 'openstation_plugins_window_field_icon_url',
77 'schema' => array(
78 'description' => __( 'Best-effort card icon URL. Prefers a local file in the plugin folder, falling back to the wp.org SVN URL; null when neither resolves.', 'desktop-mode' ),
79 'type' => array( 'string', 'null' ),
80 'context' => array( 'view', 'edit' ),
81 'readonly' => true,
82 ),
83 )
84 );
85
86 register_rest_field(
87 'plugin',
88 'openstation_size_kb',
89 array(
90 'get_callback' => 'openstation_plugins_window_field_size_kb',
91 'schema' => array(
92 'description' => __( 'Approximate disk footprint of the plugin folder, in kilobytes (cached 6h).', 'desktop-mode' ),
93 'type' => array( 'integer', 'null' ),
94 'context' => array( 'view', 'edit' ),
95 'readonly' => true,
96 ),
97 )
98 );
99
100 register_rest_field(
101 'plugin',
102 'openstation_auto_update',
103 array(
104 'get_callback' => 'openstation_plugins_window_field_auto_update',
105 'schema' => array(
106 'description' => __( 'Auto-update state for this plugin (enabled / forced / supported), mirroring Core\'s plugins.php column.', 'desktop-mode' ),
107 'type' => 'object',
108 'context' => array( 'view', 'edit' ),
109 'readonly' => true,
110 ),
111 )
112 );
113 }
114 add_action( 'rest_api_init', 'openstation_plugins_window_register_rest_fields' );
115
116 /**
117 * Resolve the plugin file path (relative to `WP_PLUGIN_DIR`, ending in
118 * `.php`) for a Core REST plugin row.
119 *
120 * Core's `WP_REST_Plugins_Controller::prepare_item_for_response` emits
121 * the `plugin` field with the trailing `.php` STRIPPED — e.g.
122 * `"elementor/elementor"` rather than `"elementor/elementor.php"`. But
123 * every internal WordPress data structure that keys off the plugin
124 * file — `update_plugins` site transient, `active_plugins` option,
125 * `plugin_basename()`, `WP_PLUGIN_DIR` paths — uses the full filename.
126 * Mixing the two yields silent lookup misses (the symptom that hid
127 * the "Update available" tab).
128 *
129 * This helper re-appends `.php` when missing so callers can use the
130 * result as a transient/option key or filesystem path directly.
131 *
132 * @param array $row Core REST plugin row.
133 * @return string Plugin file (e.g. `"elementor/elementor.php"`), or `''`
134 * when the row has no `plugin` field.
135 */
136 function openstation_plugins_window_row_plugin_file( $row ) {
137 $file = isset( $row['plugin'] ) ? (string) $row['plugin'] : '';
138 if ( '' === $file ) {
139 return '';
140 }
141 if ( '.php' !== substr( $file, -4 ) ) {
142 $file .= '.php';
143 }
144 return $file;
145 }
146
147 /**
148 * Lazily prime the `update_plugins` site transient so REST callers see
149 * the same "updates available" picture as the classic Plugins screen.
150 *
151 * Core only refreshes the transient on `load-plugins.php`,
152 * `load-update-core.php`, and the twice-daily cron — REST is not on
153 * that list, so a fresh page load of the desktop Plugins window can
154 * see an empty/stale transient even when the dock badge (computed
155 * off `$menu`, which Core builds against `wp_get_update_data()`)
156 * reports pending updates. We mirror Core's own throttle
157 * (`wp-admin/includes/update.php::_maybe_update_plugins()` — 12h since
158 * last check) so a hot REST hit is a transient read, not an HTTPS
159 * round-trip to api.wordpress.org.
160 *
161 * Idempotent on its own (Core's 12h throttle); callers that hit this
162 * many times per request should additionally guard with their own
163 * static so they don't pay the transient-read overhead per row.
164 *
165 * @param bool $force When true, delete the transient and force a fresh
166 * wp.org check regardless of the 12h throttle.
167 */
168 function openstation_plugins_window_maybe_refresh_update_transient( $force = false ) {
169 /**
170 * Short-circuit the lazy refresh of the `update_plugins` transient.
171 *
172 * Return `false` to skip the refresh — useful for hosts that run
173 * their own update orchestration (managed WordPress, internal
174 * mirrors) and don't want every REST hit to the plugins endpoint
175 * to potentially trigger a wp.org check. The filter also gates the
176 * explicit force-refresh path so hosts that block wp.org calls
177 * outright keep that posture even when the user clicks Refresh.
178 *
179 * @param bool $refresh Whether to call `wp_update_plugins()`.
180 * @param bool $force Whether the caller asked to bypass the throttle.
181 */
182 if ( ! apply_filters( 'openstation_plugins_window_refresh_updates', true, $force ) ) {
183 return;
184 }
185
186 if ( ! function_exists( 'wp_update_plugins' ) ) {
187 // `wp-includes/update.php` is normally autoloaded on every
188 // request; guard anyway so an unusual bootstrap (mu-plugin
189 // CLI harness, stripped-down REST runtime) doesn't fatal.
190 return;
191 }
192
193 if ( $force ) {
194 // Explicit user-initiated refresh — bypass the throttle.
195 // Two steps:
196 // 1. Delete the `update_plugins` site transient (and the
197 // `plugins` cache group) via `wp_clean_plugins_cache()`,
198 // OR fall back to `delete_site_transient()` directly when
199 // the admin-side helper isn't loaded.
200 // 2. Call `wp_update_plugins()` to repopulate the transient
201 // with a fresh wp.org snapshot. Without step 2 the field
202 // callback reads `false` for the rest of this request and
203 // every row reports "no updates" — that's the exact
204 // regression from the first cut of this fix (GH#202).
205 if ( function_exists( 'wp_clean_plugins_cache' ) ) {
206 wp_clean_plugins_cache( true );
207 } else {
208 delete_site_transient( 'update_plugins' );
209 }
210 wp_update_plugins();
211 return;
212 }
213
214 $current = get_site_transient( 'update_plugins' );
215 if (
216 is_object( $current ) &&
217 isset( $current->last_checked ) &&
218 12 * HOUR_IN_SECONDS > ( time() - (int) $current->last_checked )
219 ) {
220 // Inside Core's standard refresh window — trust the cached
221 // snapshot, identical to `_maybe_update_plugins()`'s posture.
222 return;
223 }
224
225 wp_update_plugins();
226 }
227
228 /**
229 * Detect whether the current REST request asked for an explicit
230 * `update_plugins` refresh via `?openstation_force_refresh=1`.
231 *
232 * The flag is set by the in-window Refresh button (see
233 * `fetchInstalledPlugins({ force: true })` in `src/plugins-window/rest.ts`)
234 * and read from the query string on the way through Core's REST
235 * dispatcher. Querystring is the canonical channel — the value is an
236 * idempotent "use the slow path" hint, not a state-changing action,
237 * so no additional nonce is required beyond REST's standard
238 * `X-WP-Nonce` cookie-auth check.
239 *
240 * @return bool True when the request asked for a force-refresh.
241 */
242 function openstation_plugins_window_force_refresh_requested() {
243 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately.
244 if ( ! isset( $_GET['openstation_force_refresh'] ) ) {
245 return false;
246 }
247 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately.
248 $value = sanitize_text_field( wp_unslash( (string) $_GET['openstation_force_refresh'] ) );
249 return '1' === $value || 'true' === $value;
250 }
251
252 /**
253 * Prime the `update_plugins` transient at most once per request.
254 */
255 function openstation_plugins_window_prime_updates_once() {
256 static $primed = false;
257 if ( $primed ) {
258 return;
259 }
260 $primed = true;
261 openstation_plugins_window_maybe_refresh_update_transient(
262 openstation_plugins_window_force_refresh_requested()
263 );
264 }
265
266 /**
267 * `openstation_update_available` callback.
268 *
269 * @param array $row Core REST plugin row.
270 * @return array{available:bool,new_version:string|null,package:string,slug:string}
271 */
272 function openstation_plugins_window_field_update_available( $row ) {
273 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
274 if ( '' === $plugin_file ) {
275 return array(
276 'available' => false,
277 'new_version' => null,
278 'package' => '',
279 'slug' => '',
280 );
281 }
282
283 // Prime the transient once per request before reading it —
284 // otherwise REST callers see a stale/empty snapshot relative to
285 // the classic Plugins screen and the dock update badge. When the
286 // request carries `?openstation_force_refresh=1` the helper always
287 // takes the slow path so the in-window Refresh button can actually
288 // pull a fresh wp.org snapshot (the original throttle made it a
289 // no-op within 12h of the last check — see GH#202).
290 openstation_plugins_window_prime_updates_once();
291
292 // `update_plugins` is the canonical site-wide cache of pending
293 // updates, refreshed by `wp_update_plugins()` on the standard
294 // schedule. Reading it costs nothing.
295 $updates = get_site_transient( 'update_plugins' );
296 if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) {
297 return array(
298 'available' => false,
299 'new_version' => null,
300 'package' => '',
301 'slug' => '',
302 );
303 }
304
305 if ( ! isset( $updates->response[ $plugin_file ] ) ) {
306 return array(
307 'available' => false,
308 'new_version' => null,
309 'package' => '',
310 'slug' => '',
311 );
312 }
313
314 $entry = $updates->response[ $plugin_file ];
315 $ver = is_object( $entry ) && isset( $entry->new_version )
316 ? (string) $entry->new_version
317 : null;
318 // `package` is the download URL Core's upgrader hits to fetch the
319 // new .zip. Empty for plugins that don't ship a wp.org package
320 // (premium / private hosts) — Core renders an "Automatic update is
321 // unavailable for this plugin" notice in that case rather than the
322 // "Update now" link. We surface the URL so JS can apply the same
323 // gating without needing a second round-trip.
324 $package = is_object( $entry ) && ! empty( $entry->package )
325 ? (string) $entry->package
326 : '';
327 // `slug` is what Core's `wp_ajax_update_plugin` echoes back in its
328 // success / error envelope. We forward what the transient already
329 // carries; the AJAX handler doesn't require it on the request
330 // side (it derives slug from `plugin`), but having it client-side
331 // keeps event payloads symmetric with Core's own.
332 $slug = is_object( $entry ) && ! empty( $entry->slug )
333 ? (string) $entry->slug
334 : '';
335
336 return array(
337 'available' => true,
338 'new_version' => $ver,
339 'package' => $package,
340 'slug' => $slug,
341 );
342 }
343
344 /**
345 * Count plugin updates visible to the Plugins window — i.e. updates in
346 * the `update_plugins` site transient whose key corresponds to an
347 * actually-installed plugin file (`get_plugins()`).
348 *
349 * Core's `wp_get_update_data()` reports `count( $update_plugins->response )`
350 * verbatim, which is what `wp-admin/menu.php` embeds in the Plugins
351 * menu title (the source the dock-builder regex captures). That raw
352 * count can drift above the in-window "Update available" filter when
353 * the transient holds orphan entries — rows for plugin files that no
354 * longer exist on disk, or rows injected via the standard `Update URI`
355 * mechanism that key on a file `get_plugins()` doesn't return.
356 *
357 * The Plugins window iterates `get_plugins()` via REST and shows each
358 * row as updatable iff `update_plugins->response[ $plugin_file ]` is
359 * set — exactly the intersection we compute here. Using this count for
360 * the dock badge guarantees the two surfaces agree (GH#258).
361 *
362 * @return int Number of installed plugins with a pending update.
363 */
364 function openstation_plugins_window_count_visible_updates() {
365 $updates = get_site_transient( 'update_plugins' );
366 if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) {
367 return 0;
368 }
369
370 // `get_plugins()` lives in `wp-admin/includes/plugin.php`. Loaded by
371 // default on every admin request (which is where `$menu` is built),
372 // but require it explicitly so REST + cron + WP-CLI callers can use
373 // this helper without depending on the admin runtime.
374 if ( ! function_exists( 'get_plugins' ) ) {
375 require_once ABSPATH . 'wp-admin/includes/plugin.php';
376 }
377 $installed = get_plugins();
378
379 $count = 0;
380 foreach ( array_keys( $updates->response ) as $plugin_file ) {
381 if ( isset( $installed[ $plugin_file ] ) ) {
382 ++$count;
383 }
384 }
385 return $count;
386 }
387
388 /**
389 * `openstation_can_manage` callback.
390 *
391 * Per-row cap surface so the JS UI can hide actions the viewer can't
392 * perform without re-deriving caps client-side. Server still
393 * re-validates every mutation.
394 *
395 * @param array $row Core REST plugin row.
396 * @return array{activate:bool,deactivate:bool,delete:bool}
397 */
398 function openstation_plugins_window_field_can_manage( $row ) {
399 $status = isset( $row['status'] ) ? (string) $row['status'] : '';
400
401 $can_activate = current_user_can( 'activate_plugins' );
402 $can_delete = current_user_can( 'delete_plugins' );
403
404 // Active plugins can only be deleted after deactivation; surface
405 // that constraint so the JS can dim the Delete action while the
406 // row is active.
407 $can_delete_now = $can_delete && 'inactive' === $status;
408
409 return array(
410 'activate' => $can_activate && 'inactive' === $status,
411 'deactivate' => $can_activate && 'active' === $status,
412 'delete' => $can_delete_now,
413 );
414 }
415
416 /**
417 * What wp.org last said about one plugin — `slug`, `icons`, versions.
418 *
419 * Both halves have to be read: a plugin is filed under `response`
420 * when an update is pending and `no_update` otherwise, with the same
421 * directory metadata in each. Reading only `response` misses every
422 * up-to-date plugin.
423 *
424 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
425 * @return array|null Null when wp.org doesn't know this plugin, or the
426 * transient is cold.
427 */
428 function openstation_plugins_window_update_entry( $plugin_file ) {
429 if ( '' === $plugin_file ) {
430 return null;
431 }
432
433 openstation_plugins_window_prime_updates_once();
434
435 $updates = get_site_transient( 'update_plugins' );
436 if ( ! is_object( $updates ) ) {
437 return null;
438 }
439 if ( isset( $updates->response[ $plugin_file ] ) ) {
440 return (array) $updates->response[ $plugin_file ];
441 }
442 if ( isset( $updates->no_update[ $plugin_file ] ) ) {
443 return (array) $updates->no_update[ $plugin_file ];
444 }
445
446 return null;
447 }
448
449 /**
450 * `openstation_wporg_slug` callback.
451 *
452 * Is this plugin listed on the WordPress.org directory, and under
453 * which slug?
454 *
455 * This mirrors Core (see `WP_Plugins_List_Table::prepare_items()`).
456 *
457 * @param array $row Core REST plugin row.
458 * @return string|null Directory slug, or null when the plugin isn't listed.
459 */
460 function openstation_plugins_window_field_wporg_slug( $row ) {
461 $entry = openstation_plugins_window_update_entry(
462 openstation_plugins_window_row_plugin_file( $row )
463 );
464
465 if ( null === $entry || empty( $entry['slug'] ) ) {
466 return null;
467 }
468
469 $slug = sanitize_key( (string) $entry['slug'] );
470
471 return '' !== $slug ? $slug : null;
472 }
473
474 /**
475 * `openstation_icon_url` callback.
476 *
477 * Resolves a card icon URL for an installed plugin row, in priority:
478 *
479 * 1. **Local file** — if the plugin's own folder ships an icon at a
480 * conventional path (`assets/icon.svg`, `assets/icon-256x256.png`,
481 * `assets/icon-128x128.png`, or the same names at the folder
482 * root), return its `plugins_url()`. This is what makes premium /
483 * internal / native-bundled plugins (alcazaba-*, os-*,
484 * and any private plugin that ships its own art) display
485 * correctly — they aren't on `ps.w.org/<slug>/`, so the wp.org
486 * candidate chain 404s through every variant before the
487 * placeholder paints.
488 * 2. **The `icons` map wp.org returned** for this plugin, cached in
489 * the `update_plugins` transient — a URL the directory gave us
490 * rather than one we built.
491 * 3. **Guessed SVN asset** — `https://ps.w.org/<slug>/assets/icon.svg`,
492 * for when that metadata isn't cached. `<slug>` prefers the
493 * directory slug, then the folder name, then the textdomain.
494 * Last, because both halves of the guess are unknowable: the
495 * format (Gutenberg and UpdraftPlus ship JPEG only) and the slug
496 * (`hello.php` is listed as `hello-dolly`).
497 *
498 * Skipped entirely when step 2 established the plugin uploaded no art:
499 * `null` paints the placeholder without a request, where guessing would
500 * spend a 404 per candidate arriving at the same picture.
501 *
502 * @param array $row Core REST plugin row.
503 * @return string|null
504 */
505 function openstation_plugins_window_field_icon_url( $row ) {
506 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
507 $entry = openstation_plugins_window_update_entry( $plugin_file );
508 $folder = '' !== $plugin_file ? dirname( $plugin_file ) : '';
509 $slug = ( '' !== $folder && '.' !== $folder ) ? $folder : '';
510
511 // wp.org's own slug when it knows the plugin; the rest are inferred.
512 if ( null !== $entry && ! empty( $entry['slug'] ) ) {
513 $slug = (string) $entry['slug'];
514 } elseif ( '' === $slug ) {
515 // Single-file plugin (e.g. hello.php at the plugins root) —
516 // no folder slug, so fall back to the text domain.
517 $slug = isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '';
518 }
519
520 $slug = sanitize_key( $slug );
521 if ( '' === $slug ) {
522 return null;
523 }
524
525 $default = openstation_plugins_window_local_icon_url( $plugin_file );
526 $no_art = false;
527 if ( null === $default && null !== $entry ) {
528 $default = openstation_plugins_window_directory_icon_url( $entry );
529 $no_art = ( null === $default && ! empty( $entry['icons'] ) );
530 }
531 if ( null === $default && ! $no_art ) {
532 /*
533 * Plugin Check's offloading rule is right in general and does
534 * not fit here, so the suppression is one line wide and says
535 * why, rather than living in a project-wide ignore list where
536 * it would also cover the next offload someone adds.
537 *
538 * `ps.w.org` is WordPress.org's own plugin-asset host — the
539 * same origin core's "Add Plugins" screen paints its cards
540 * from. This is directory artwork for plugins we do not ship
541 * and cannot bundle: there is nothing local to offload FROM,
542 * and the alternative is not "host it ourselves" but "no
543 * icon".
544 *
545 * It is already the last resort. The local-icon lookup above
546 * wins whenever a plugin ships art at a conventional path,
547 * nothing here is enqueued (it becomes an `<img src>`, not a
548 * script or a stylesheet), and the card walks a candidate
549 * chain before falling back to a dashicon placeholder — so a
550 * blocked or offline host costs the user the picture and
551 * nothing else.
552 */
553 // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- wp.org's own asset host, for directory art this plugin cannot bundle; degrades to a placeholder.
554 $default = 'https://ps.w.org/' . $slug . '/assets/icon.svg';
555 }
556
557 /**
558 * Filter the resolved icon URL for a plugin row.
559 *
560 * Return `null` to suppress the icon (forces the placeholder).
561 * Return a different URL to override the default — useful for
562 * custom CDN art or for overriding the auto-detected local icon.
563 *
564 * The `$url` parameter is a local `plugins_url()`, a URL from
565 * wp.org's `icons` map, or the guessed
566 * `ps.w.org/<slug>/assets/icon.svg`. Only that last shape walks the
567 * JS candidate chain on `<img>` error; every other URL is one-shot,
568 * then placeholder.
569 *
570 * @param string|null $url Default URL — see the ladder above.
571 * @param string $slug Directory slug when wp.org knows the
572 * plugin, else folder name or textdomain.
573 * @param array $row Core REST plugin row.
574 */
575 return apply_filters(
576 'openstation_plugins_window_icon_url',
577 $default,
578 $slug,
579 $row
580 );
581 }
582
583 /**
584 * Pick a card icon out of the `icons` map wp.org returned.
585 *
586 * `svg` → `2x` → `1x`, the ladder core's Add Plugins cards use (see
587 * `WP_Plugin_Install_List_Table::display_rows()`); matching it is what
588 * makes the two screens agree. `default` — wp.org's geopattern for
589 * plugins that uploaded no art — is skipped, so those keep the
590 * window's own placeholder.
591 *
592 * @param array $entry An `update_plugins` entry.
593 * @return string|null Null when the entry carries no art.
594 */
595 function openstation_plugins_window_directory_icon_url( $entry ) {
596 if ( empty( $entry['icons'] ) || ! is_array( $entry['icons'] ) ) {
597 return null;
598 }
599
600 $icons = $entry['icons'];
601 foreach ( array( 'svg', '2x', '1x' ) as $size ) {
602 if ( empty( $icons[ $size ] ) || ! is_string( $icons[ $size ] ) ) {
603 continue;
604 }
605 $url = esc_url_raw( $icons[ $size ] );
606 if ( '' !== $url ) {
607 return $url;
608 }
609 }
610
611 return null;
612 }
613
614 /**
615 * Probe an installed plugin's own folder for a card icon.
616 *
617 * Many premium and private plugins (and our own native extensions —
618 * alcazaba-*, os-*) aren't on the .org repo, so the wp.org
619 * SVN URL 404s through every candidate before the placeholder paints.
620 * Most that ship art do so at a conventional location inside their
621 * own folder — typically `assets/icon.svg` mirroring the wp.org SVN
622 * /assets/ layout, occasionally bare `icon.svg` at the root for
623 * minimal plugins. We probe both shapes and return the first URL we
624 * resolve, or `null` when nothing matches.
625 *
626 * Single-file plugins (no folder) return `null` immediately — there's
627 * no folder to scan.
628 *
629 * Cost: 1–6 `file_exists()` calls per row, ~1µs each with warm OS
630 * cache. For a 50-row paint this is well under a millisecond — not
631 * worth caching, and a cache would have to invalidate on plugin
632 * install/update/delete.
633 *
634 * The candidate list is filterable via
635 * `openstation_plugins_window_local_icon_candidates` so a host can
636 * support a custom convention (e.g. an `icon@2x.svg` shape).
637 *
638 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
639 * @return string|null URL of the first local icon found, or null.
640 */
641 function openstation_plugins_window_local_icon_url( $plugin_file ) {
642 if ( '' === $plugin_file ) {
643 return null;
644 }
645 $folder = dirname( $plugin_file );
646 if ( '' === $folder || '.' === $folder ) {
647 // Single-file plugin — no folder to scan.
648 return null;
649 }
650
651 /**
652 * Filter the ordered list of relative paths probed inside an
653 * installed plugin's folder when looking for a card icon. The
654 * first existing file wins; later entries are ignored.
655 *
656 * @param string[] $candidates Relative paths under the plugin folder.
657 * @param string $folder Plugin folder name (e.g. `"akismet"`).
658 */
659 $candidates = apply_filters(
660 'openstation_plugins_window_local_icon_candidates',
661 array(
662 'assets/icon.svg',
663 'assets/icon-256x256.png',
664 'assets/icon-256x256.jpg',
665 'assets/icon-256x256.jpeg',
666 'assets/icon-128x128.png',
667 'assets/icon-128x128.jpg',
668 'assets/icon-128x128.jpeg',
669 'icon.svg',
670 'icon-256x256.png',
671 'icon-256x256.jpg',
672 'icon-256x256.jpeg',
673 'icon-128x128.png',
674 'icon-128x128.jpg',
675 'icon-128x128.jpeg',
676 ),
677 $folder
678 );
679
680 $plugin_root = WP_PLUGIN_DIR . '/' . $folder;
681 foreach ( (array) $candidates as $relative ) {
682 $relative = (string) $relative;
683 if ( '' === $relative ) {
684 continue;
685 }
686 if ( file_exists( $plugin_root . '/' . $relative ) ) {
687 return plugins_url( $relative, WP_PLUGIN_DIR . '/' . $plugin_file );
688 }
689 }
690
691 return null;
692 }
693
694 /**
695 * `openstation_size_kb` callback. Caches per-plugin for 6 hours so
696 * a 50-row table doesn't `glob`+`filesize` 50 directories on every
697 * fetch. Returns `null` when the folder can't be read.
698 *
699 * @param array $row Core REST plugin row.
700 * @return int|null Size in kilobytes, or null on failure.
701 */
702 function openstation_plugins_window_field_size_kb( $row ) {
703 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
704 if ( '' === $plugin_file ) {
705 return null;
706 }
707
708 // `WP_PLUGIN_DIR` is defined in `wp-includes/default-constants.php`
709 // — safe to reference anywhere.
710 $plugin_dir = WP_PLUGIN_DIR;
711 $root = $plugin_dir . '/' . dirname( $plugin_file );
712 if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) {
713 // Single-file plugins (e.g. hello.php at the root of plugins/).
714 $candidate = $plugin_dir . '/' . $plugin_file;
715 if ( is_file( $candidate ) ) {
716 $bytes = (int) filesize( $candidate );
717 return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0;
718 }
719 return null;
720 }
721
722 $cache_key = 'dm_pwsz_' . md5( $plugin_file );
723 $cached = get_transient( $cache_key );
724 if ( false !== $cached && is_int( $cached ) ) {
725 return $cached;
726 }
727
728 $kb = openstation_plugins_window_compute_dir_size_kb( $root );
729 set_transient( $cache_key, $kb, 6 * HOUR_IN_SECONDS );
730 return $kb;
731 }
732
733 /**
734 * Recursively sum file sizes under `$dir`, returning kilobytes.
735 *
736 * Caps total iteration to 5,000 entries so a pathological symlink
737 * loop (or an enormous plugin folder full of vendor cruft) can't
738 * stall a REST response. When the cap trips we return whatever we
739 * counted so far — a slight under-report is better than a hung
740 * request.
741 *
742 * @param string $dir Absolute filesystem path.
743 * @return int Kilobytes (rounded).
744 */
745 function openstation_plugins_window_compute_dir_size_kb( $dir ) {
746 if ( ! is_dir( $dir ) ) {
747 return 0;
748 }
749
750 $total_bytes = 0;
751 $visited = 0;
752 $max_visit = 5000;
753
754 $stack = array( $dir );
755 while ( ! empty( $stack ) && $visited < $max_visit ) {
756 $current = array_pop( $stack );
757 $entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null.
758 if ( ! is_array( $entries ) ) {
759 continue;
760 }
761 foreach ( $entries as $entry ) {
762 if ( '.' === $entry || '..' === $entry ) {
763 continue;
764 }
765 $path = $current . '/' . $entry;
766 if ( is_link( $path ) ) {
767 // Skip symlinks: they could escape the plugin folder
768 // or recurse infinitely. The classic admin's plugin
769 // list ignores symlink contents for the same reason.
770 continue;
771 }
772 ++$visited;
773 if ( $visited >= $max_visit ) {
774 break 2;
775 }
776 if ( is_dir( $path ) ) {
777 $stack[] = $path;
778 } elseif ( is_file( $path ) ) {
779 $total_bytes += (int) filesize( $path );
780 }
781 }
782 }
783
784 return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0;
785 }
786
787 /**
788 * `openstation_auto_update` callback.
789 *
790 * Mirrors the per-row state Core derives in
791 * `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates"
792 * column. Shape:
793 *
794 * - `enabled` bool — the plugin file is currently in the
795 * `auto_update_plugins` site option, OR a
796 * filter has forced auto-updates on.
797 * - `forced` bool|null — `true`/`false` when the
798 * `auto_update_plugin` filter pinned the state,
799 * `null` when the user is free to toggle.
800 * - `supported` bool — whether the `update_plugins` transient has an
801 * entry for this plugin (either in `response` or
802 * `no_update`). Core hides the toggle entirely
803 * when this is false — premium / private plugins
804 * that never check in with wp.org.
805 *
806 * NOT included here (lives on the window config instead): the global
807 * `wp_is_auto_update_enabled_for_type( 'plugin' )` flag, which depends
808 * on admin-only includes — see `openstation_plugins_window_auto_updates_enabled()`.
809 *
810 * @param array $row Core REST plugin row.
811 * @return array{enabled:bool,forced:bool|null,supported:bool}
812 */
813 function openstation_plugins_window_field_auto_update( $row ) {
814 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
815 if ( '' === $plugin_file ) {
816 return array(
817 'enabled' => false,
818 'forced' => null,
819 'supported' => false,
820 );
821 }
822
823 $auto_updates = (array) get_site_option( 'auto_update_plugins', array() );
824 $enabled = in_array( $plugin_file, $auto_updates, true );
825
826 // `update-supported` mirrors Core's logic: a plugin is "supported"
827 // for auto-update toggling when wp.org has either a pending update
828 // row OR an explicit no-update row in the `update_plugins` transient.
829 // Premium / private plugins that never call home land in neither
830 // bucket — Core hides the toggle so the user doesn't enable an
831 // auto-update that can't ever fire.
832 $supported = false;
833 $updates = get_site_transient( 'update_plugins' );
834 if ( is_object( $updates ) ) {
835 if ( isset( $updates->response[ $plugin_file ] ) || isset( $updates->no_update[ $plugin_file ] ) ) {
836 $supported = true;
837 }
838 }
839
840 // Build the payload Core's filter expects (mirrors
841 // `WP_Plugins_List_Table::prepare_items()`'s `$filter_payload`).
842 // `wp_is_auto_update_forced_for_item()` itself is in
843 // `wp-admin/includes/update.php` — we can't include that from a REST
844 // callback (Plugin Check), so we run the filter directly. It's a
845 // single `apply_filters()` call under the hood.
846 //
847 // Important: `wp_parse_args( $row, $defaults )` lets `$row` keys
848 // override `$defaults`. Core's REST controller strips `.php` from
849 // the `plugin` field, but every filter that hooks `auto_update_plugin`
850 // (including Core's own) reads `$item->plugin` expecting the FULL
851 // filename. We layer the normalized `$plugin_file` AFTER the parse
852 // so it always wins.
853 $filter_payload = wp_parse_args(
854 $row,
855 array(
856 'id' => $plugin_file,
857 'slug' => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '',
858 'plugin' => $plugin_file,
859 'new_version' => '',
860 'url' => '',
861 'package' => '',
862 'icons' => array(),
863 'banners' => array(),
864 'banners_rtl' => array(),
865 'tested' => '',
866 'requires_php' => '',
867 'compatibility' => new stdClass(),
868 )
869 );
870 $filter_payload['plugin'] = $plugin_file;
871 $filter_payload['id'] = $plugin_file;
872 $filter_payload = (object) $filter_payload;
873 /** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */
874 $forced = apply_filters( 'auto_update_plugin', null, $filter_payload ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter; the effective auto-update state has to come from the same source Core reads.
875 if ( null !== $forced ) {
876 $forced = (bool) $forced;
877 // When a filter forces the state, that's the effective state
878 // regardless of the `auto_update_plugins` option — match Core's
879 // rendering in `single_row_columns()`.
880 $enabled = $forced;
881 }
882
883 return array(
884 'enabled' => (bool) $enabled,
885 'forced' => $forced,
886 'supported' => $supported,
887 );
888 }
889