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

818 lines 29.9 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 * `openstation_wporg_slug` callback.
418 *
419 * Is this plugin listed on the WordPress.org directory, and under
420 * which slug?
421 *
422 * This mirrors Core (see `WP_Plugins_List_Table::prepare_items()`).
423 *
424 * @param array $row Core REST plugin row.
425 * @return string|null Directory slug, or null when the plugin isn't listed.
426 */
427 function openstation_plugins_window_field_wporg_slug( $row ) {
428 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
429
430 openstation_plugins_window_prime_updates_once();
431
432 $slug = '';
433 if ( '' !== $plugin_file ) {
434 $updates = get_site_transient( 'update_plugins' );
435 if ( is_object( $updates ) ) {
436 $entry = null;
437 if ( isset( $updates->response[ $plugin_file ] ) ) {
438 $entry = (array) $updates->response[ $plugin_file ];
439 } elseif ( isset( $updates->no_update[ $plugin_file ] ) ) {
440 $entry = (array) $updates->no_update[ $plugin_file ];
441 }
442 if ( null !== $entry && ! empty( $entry['slug'] ) ) {
443 $slug = sanitize_key( (string) $entry['slug'] );
444 }
445 }
446 }
447
448 return '' !== $slug ? $slug : null;
449 }
450
451 /**
452 * `openstation_icon_url` callback.
453 *
454 * Resolves a card icon URL for an installed plugin row, in priority:
455 *
456 * 1. **Local file** — if the plugin's own folder ships an icon at a
457 * conventional path (`assets/icon.svg`, `assets/icon-256x256.png`,
458 * `assets/icon-128x128.png`, or the same names at the folder
459 * root), return its `plugins_url()`. This is what makes premium /
460 * internal / native-bundled plugins (alcazaba-*, os-*,
461 * and any private plugin that ships its own art) display
462 * correctly — they aren't on `ps.w.org/<slug>/`, so the wp.org
463 * candidate chain 404s through every variant before the
464 * placeholder paints.
465 * 2. **wp.org SVN asset** — `https://ps.w.org/<slug>/assets/icon.svg`,
466 * keyed off the plugin's **folder name** (which is the .org repo
467 * slug). Folder beats textdomain because the two often diverge
468 * (`woocommerce` vs textdomain `woo`, `wordpress-seo` vs
469 * `yoast-seo`). Falls back to textdomain for single-file plugins.
470 *
471 * We don't HEAD-check the URL — the JS card walks a candidate chain
472 * (SVG → 256 PNG → 256 GIF → 128 PNG → 128 GIF) on `<img>` error for wp.org URLs, then
473 * drops to a `<os-icon name="dashicons-admin-plugins">` placeholder.
474 * A 404 here costs nothing.
475 *
476 * @param array $row Core REST plugin row.
477 * @return string|null
478 */
479 function openstation_plugins_window_field_icon_url( $row ) {
480 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
481 $folder = '' !== $plugin_file ? dirname( $plugin_file ) : '';
482 $slug = ( '' !== $folder && '.' !== $folder ) ? $folder : '';
483
484 if ( '' === $slug ) {
485 // Single-file plugin (e.g. hello.php at the plugins root) —
486 // no folder slug, so fall back to the text domain.
487 $slug = isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '';
488 }
489
490 $slug = sanitize_key( $slug );
491 if ( '' === $slug ) {
492 return null;
493 }
494
495 $default = openstation_plugins_window_local_icon_url( $plugin_file );
496 if ( null === $default ) {
497 /*
498 * Plugin Check's offloading rule is right in general and does
499 * not fit here, so the suppression is one line wide and says
500 * why, rather than living in a project-wide ignore list where
501 * it would also cover the next offload someone adds.
502 *
503 * `ps.w.org` is WordPress.org's own plugin-asset host — the
504 * same origin core's "Add Plugins" screen paints its cards
505 * from. This is directory artwork for plugins we do not ship
506 * and cannot bundle: there is nothing local to offload FROM,
507 * and the alternative is not "host it ourselves" but "no
508 * icon".
509 *
510 * It is already the last resort. The local-icon lookup above
511 * wins whenever a plugin ships art at a conventional path,
512 * nothing here is enqueued (it becomes an `<img src>`, not a
513 * script or a stylesheet), and the card walks a candidate
514 * chain before falling back to a dashicon placeholder — so a
515 * blocked or offline host costs the user the picture and
516 * nothing else.
517 */
518 // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- wp.org's own asset host, for directory art this plugin cannot bundle; degrades to a placeholder.
519 $default = 'https://ps.w.org/' . $slug . '/assets/icon.svg';
520 }
521
522 /**
523 * Filter the resolved icon URL for a plugin row.
524 *
525 * Return `null` to suppress the icon (forces the placeholder).
526 * Return a different URL to override the default — useful for
527 * custom CDN art or for overriding the auto-detected local icon.
528 *
529 * The `$url` parameter is either a local `plugins_url()` (when the
530 * plugin's own folder ships an icon at a conventional path) or the
531 * wp.org `ps.w.org/<slug>/assets/icon.svg` URL. The JS receiver
532 * walks a candidate chain on `<img>` error (`icon.svg` → 256 PNG →
533 * 256 GIF → 128 PNG → 128 GIF) only when the URL matches the
534 * wp.org SVN pattern;
535 * custom URLs and local URLs are one-shot, then placeholder.
536 *
537 * @param string|null $url Default URL (local file if the plugin's
538 * folder ships one, else wp.org SVG).
539 * @param string $slug Plugin slug (folder name, or textdomain
540 * for single-file plugins).
541 * @param array $row Core REST plugin row.
542 */
543 return apply_filters(
544 'openstation_plugins_window_icon_url',
545 $default,
546 $slug,
547 $row
548 );
549 }
550
551 /**
552 * Probe an installed plugin's own folder for a card icon.
553 *
554 * Many premium and private plugins (and our own native extensions —
555 * alcazaba-*, os-*) aren't on the .org repo, so the wp.org
556 * SVN URL 404s through every candidate before the placeholder paints.
557 * Most that ship art do so at a conventional location inside their
558 * own folder — typically `assets/icon.svg` mirroring the wp.org SVN
559 * /assets/ layout, occasionally bare `icon.svg` at the root for
560 * minimal plugins. We probe both shapes and return the first URL we
561 * resolve, or `null` when nothing matches.
562 *
563 * Single-file plugins (no folder) return `null` immediately — there's
564 * no folder to scan.
565 *
566 * Cost: 1–6 `file_exists()` calls per row, ~1µs each with warm OS
567 * cache. For a 50-row paint this is well under a millisecond — not
568 * worth caching, and a cache would have to invalidate on plugin
569 * install/update/delete.
570 *
571 * The candidate list is filterable via
572 * `openstation_plugins_window_local_icon_candidates` so a host can
573 * support a custom convention (e.g. an `icon@2x.svg` shape).
574 *
575 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
576 * @return string|null URL of the first local icon found, or null.
577 */
578 function openstation_plugins_window_local_icon_url( $plugin_file ) {
579 if ( '' === $plugin_file ) {
580 return null;
581 }
582 $folder = dirname( $plugin_file );
583 if ( '' === $folder || '.' === $folder ) {
584 // Single-file plugin — no folder to scan.
585 return null;
586 }
587
588 /**
589 * Filter the ordered list of relative paths probed inside an
590 * installed plugin's folder when looking for a card icon. The
591 * first existing file wins; later entries are ignored.
592 *
593 * @param string[] $candidates Relative paths under the plugin folder.
594 * @param string $folder Plugin folder name (e.g. `"akismet"`).
595 */
596 $candidates = apply_filters(
597 'openstation_plugins_window_local_icon_candidates',
598 array(
599 'assets/icon.svg',
600 'assets/icon-256x256.png',
601 'assets/icon-128x128.png',
602 'icon.svg',
603 'icon-256x256.png',
604 'icon-128x128.png',
605 ),
606 $folder
607 );
608
609 $plugin_root = WP_PLUGIN_DIR . '/' . $folder;
610 foreach ( (array) $candidates as $relative ) {
611 $relative = (string) $relative;
612 if ( '' === $relative ) {
613 continue;
614 }
615 if ( file_exists( $plugin_root . '/' . $relative ) ) {
616 return plugins_url( $relative, WP_PLUGIN_DIR . '/' . $plugin_file );
617 }
618 }
619
620 return null;
621 }
622
623 /**
624 * `openstation_size_kb` callback. Caches per-plugin for 6 hours so
625 * a 50-row table doesn't `glob`+`filesize` 50 directories on every
626 * fetch. Returns `null` when the folder can't be read.
627 *
628 * @param array $row Core REST plugin row.
629 * @return int|null Size in kilobytes, or null on failure.
630 */
631 function openstation_plugins_window_field_size_kb( $row ) {
632 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
633 if ( '' === $plugin_file ) {
634 return null;
635 }
636
637 // `WP_PLUGIN_DIR` is defined in `wp-includes/default-constants.php`
638 // — safe to reference anywhere.
639 $plugin_dir = WP_PLUGIN_DIR;
640 $root = $plugin_dir . '/' . dirname( $plugin_file );
641 if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) {
642 // Single-file plugins (e.g. hello.php at the root of plugins/).
643 $candidate = $plugin_dir . '/' . $plugin_file;
644 if ( is_file( $candidate ) ) {
645 $bytes = (int) filesize( $candidate );
646 return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0;
647 }
648 return null;
649 }
650
651 $cache_key = 'dm_pwsz_' . md5( $plugin_file );
652 $cached = get_transient( $cache_key );
653 if ( false !== $cached && is_int( $cached ) ) {
654 return $cached;
655 }
656
657 $kb = openstation_plugins_window_compute_dir_size_kb( $root );
658 set_transient( $cache_key, $kb, 6 * HOUR_IN_SECONDS );
659 return $kb;
660 }
661
662 /**
663 * Recursively sum file sizes under `$dir`, returning kilobytes.
664 *
665 * Caps total iteration to 5,000 entries so a pathological symlink
666 * loop (or an enormous plugin folder full of vendor cruft) can't
667 * stall a REST response. When the cap trips we return whatever we
668 * counted so far — a slight under-report is better than a hung
669 * request.
670 *
671 * @param string $dir Absolute filesystem path.
672 * @return int Kilobytes (rounded).
673 */
674 function openstation_plugins_window_compute_dir_size_kb( $dir ) {
675 if ( ! is_dir( $dir ) ) {
676 return 0;
677 }
678
679 $total_bytes = 0;
680 $visited = 0;
681 $max_visit = 5000;
682
683 $stack = array( $dir );
684 while ( ! empty( $stack ) && $visited < $max_visit ) {
685 $current = array_pop( $stack );
686 $entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null.
687 if ( ! is_array( $entries ) ) {
688 continue;
689 }
690 foreach ( $entries as $entry ) {
691 if ( '.' === $entry || '..' === $entry ) {
692 continue;
693 }
694 $path = $current . '/' . $entry;
695 if ( is_link( $path ) ) {
696 // Skip symlinks: they could escape the plugin folder
697 // or recurse infinitely. The classic admin's plugin
698 // list ignores symlink contents for the same reason.
699 continue;
700 }
701 ++$visited;
702 if ( $visited >= $max_visit ) {
703 break 2;
704 }
705 if ( is_dir( $path ) ) {
706 $stack[] = $path;
707 } elseif ( is_file( $path ) ) {
708 $total_bytes += (int) filesize( $path );
709 }
710 }
711 }
712
713 return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0;
714 }
715
716 /**
717 * `openstation_auto_update` callback.
718 *
719 * Mirrors the per-row state Core derives in
720 * `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates"
721 * column. Shape:
722 *
723 * - `enabled` bool — the plugin file is currently in the
724 * `auto_update_plugins` site option, OR a
725 * filter has forced auto-updates on.
726 * - `forced` bool|null — `true`/`false` when the
727 * `auto_update_plugin` filter pinned the state,
728 * `null` when the user is free to toggle.
729 * - `supported` bool — whether the `update_plugins` transient has an
730 * entry for this plugin (either in `response` or
731 * `no_update`). Core hides the toggle entirely
732 * when this is false — premium / private plugins
733 * that never check in with wp.org.
734 *
735 * NOT included here (lives on the window config instead): the global
736 * `wp_is_auto_update_enabled_for_type( 'plugin' )` flag, which depends
737 * on admin-only includes — see `openstation_plugins_window_auto_updates_enabled()`.
738 *
739 * @param array $row Core REST plugin row.
740 * @return array{enabled:bool,forced:bool|null,supported:bool}
741 */
742 function openstation_plugins_window_field_auto_update( $row ) {
743 $plugin_file = openstation_plugins_window_row_plugin_file( $row );
744 if ( '' === $plugin_file ) {
745 return array(
746 'enabled' => false,
747 'forced' => null,
748 'supported' => false,
749 );
750 }
751
752 $auto_updates = (array) get_site_option( 'auto_update_plugins', array() );
753 $enabled = in_array( $plugin_file, $auto_updates, true );
754
755 // `update-supported` mirrors Core's logic: a plugin is "supported"
756 // for auto-update toggling when wp.org has either a pending update
757 // row OR an explicit no-update row in the `update_plugins` transient.
758 // Premium / private plugins that never call home land in neither
759 // bucket — Core hides the toggle so the user doesn't enable an
760 // auto-update that can't ever fire.
761 $supported = false;
762 $updates = get_site_transient( 'update_plugins' );
763 if ( is_object( $updates ) ) {
764 if ( isset( $updates->response[ $plugin_file ] ) || isset( $updates->no_update[ $plugin_file ] ) ) {
765 $supported = true;
766 }
767 }
768
769 // Build the payload Core's filter expects (mirrors
770 // `WP_Plugins_List_Table::prepare_items()`'s `$filter_payload`).
771 // `wp_is_auto_update_forced_for_item()` itself is in
772 // `wp-admin/includes/update.php` — we can't include that from a REST
773 // callback (Plugin Check), so we run the filter directly. It's a
774 // single `apply_filters()` call under the hood.
775 //
776 // Important: `wp_parse_args( $row, $defaults )` lets `$row` keys
777 // override `$defaults`. Core's REST controller strips `.php` from
778 // the `plugin` field, but every filter that hooks `auto_update_plugin`
779 // (including Core's own) reads `$item->plugin` expecting the FULL
780 // filename. We layer the normalized `$plugin_file` AFTER the parse
781 // so it always wins.
782 $filter_payload = wp_parse_args(
783 $row,
784 array(
785 'id' => $plugin_file,
786 'slug' => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '',
787 'plugin' => $plugin_file,
788 'new_version' => '',
789 'url' => '',
790 'package' => '',
791 'icons' => array(),
792 'banners' => array(),
793 'banners_rtl' => array(),
794 'tested' => '',
795 'requires_php' => '',
796 'compatibility' => new stdClass(),
797 )
798 );
799 $filter_payload['plugin'] = $plugin_file;
800 $filter_payload['id'] = $plugin_file;
801 $filter_payload = (object) $filter_payload;
802 /** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */
803 $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.
804 if ( null !== $forced ) {
805 $forced = (bool) $forced;
806 // When a filter forces the state, that's the effective state
807 // regardless of the `auto_update_plugins` option — match Core's
808 // rendering in `single_row_columns()`.
809 $enabled = $forced;
810 }
811
812 return array(
813 'enabled' => (bool) $enabled,
814 'forced' => $forced,
815 'supported' => $supported,
816 );
817 }
818