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

738 lines 26.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — 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 * - desktop_mode_update_available — `{ available, new_version }`
9 * - desktop_mode_can_manage — `{ activate, deactivate, delete }`
10 * - desktop_mode_icon_url — local-folder icon, falling back to wp.org
11 * - desktop_mode_size_kb — disk size of plugin folder
12 * - desktop_mode_auto_update — `{ enabled, forced, supported }`
13 *
14 * Plugin Check posture: every callback below uses ONLY functions
15 * available in `wp-includes/` (current_user_can, get_site_transient,
16 * filesize, glob, …). No admin-only includes are needed, so REST is
17 * the right home — registering these fields on `rest_api_init` keeps
18 * the contract consistent with Core's other plugin REST decorators.
19 *
20 * @package WPDesktopMode
21 * @since 0.9.0
22 */
23
24 defined( 'ABSPATH' ) || exit;
25
26 /**
27 * Register the five enrichment fields on the `plugin` REST resource.
28 *
29 * @since 0.9.0
30 * @since 0.21.0 Added `desktop_mode_auto_update`.
31 */
32 function desktop_mode_plugins_window_register_rest_fields() {
33 register_rest_field(
34 'plugin',
35 'desktop_mode_update_available',
36 array(
37 'get_callback' => 'desktop_mode_plugins_window_field_update_available',
38 'schema' => array(
39 'description' => __( 'Whether an update is available for this plugin (and the available version).', 'desktop-mode' ),
40 'type' => 'object',
41 'context' => array( 'view', 'edit' ),
42 'readonly' => true,
43 ),
44 )
45 );
46
47 register_rest_field(
48 'plugin',
49 'desktop_mode_can_manage',
50 array(
51 'get_callback' => 'desktop_mode_plugins_window_field_can_manage',
52 'schema' => array(
53 'description' => __( 'Per-plugin capability flags for the requester (activate / deactivate / delete).', 'desktop-mode' ),
54 'type' => 'object',
55 'context' => array( 'view', 'edit' ),
56 'readonly' => true,
57 ),
58 )
59 );
60
61 register_rest_field(
62 'plugin',
63 'desktop_mode_icon_url',
64 array(
65 'get_callback' => 'desktop_mode_plugins_window_field_icon_url',
66 'schema' => array(
67 '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' ),
68 'type' => array( 'string', 'null' ),
69 'context' => array( 'view', 'edit' ),
70 'readonly' => true,
71 ),
72 )
73 );
74
75 register_rest_field(
76 'plugin',
77 'desktop_mode_size_kb',
78 array(
79 'get_callback' => 'desktop_mode_plugins_window_field_size_kb',
80 'schema' => array(
81 'description' => __( 'Approximate disk footprint of the plugin folder, in kilobytes (cached 6h).', 'desktop-mode' ),
82 'type' => array( 'integer', 'null' ),
83 'context' => array( 'view', 'edit' ),
84 'readonly' => true,
85 ),
86 )
87 );
88
89 register_rest_field(
90 'plugin',
91 'desktop_mode_auto_update',
92 array(
93 'get_callback' => 'desktop_mode_plugins_window_field_auto_update',
94 'schema' => array(
95 'description' => __( 'Auto-update state for this plugin (enabled / forced / supported), mirroring Core\'s plugins.php column.', 'desktop-mode' ),
96 'type' => 'object',
97 'context' => array( 'view', 'edit' ),
98 'readonly' => true,
99 ),
100 )
101 );
102 }
103 add_action( 'rest_api_init', 'desktop_mode_plugins_window_register_rest_fields' );
104
105 /**
106 * Resolve the plugin file path (relative to `WP_PLUGIN_DIR`, ending in
107 * `.php`) for a Core REST plugin row.
108 *
109 * Core's `WP_REST_Plugins_Controller::prepare_item_for_response` emits
110 * the `plugin` field with the trailing `.php` STRIPPED — e.g.
111 * `"elementor/elementor"` rather than `"elementor/elementor.php"`. But
112 * every internal WordPress data structure that keys off the plugin
113 * file — `update_plugins` site transient, `active_plugins` option,
114 * `plugin_basename()`, `WP_PLUGIN_DIR` paths — uses the full filename.
115 * Mixing the two yields silent lookup misses (the symptom that hid
116 * the "Update available" tab).
117 *
118 * This helper re-appends `.php` when missing so callers can use the
119 * result as a transient/option key or filesystem path directly.
120 *
121 * @since 0.18.0
122 *
123 * @param array $row Core REST plugin row.
124 * @return string Plugin file (e.g. `"elementor/elementor.php"`), or `''`
125 * when the row has no `plugin` field.
126 */
127 function desktop_mode_plugins_window_row_plugin_file( $row ) {
128 $file = isset( $row['plugin'] ) ? (string) $row['plugin'] : '';
129 if ( '' === $file ) {
130 return '';
131 }
132 if ( '.php' !== substr( $file, -4 ) ) {
133 $file .= '.php';
134 }
135 return $file;
136 }
137
138 /**
139 * Lazily prime the `update_plugins` site transient so REST callers see
140 * the same "updates available" picture as the classic Plugins screen.
141 *
142 * Core only refreshes the transient on `load-plugins.php`,
143 * `load-update-core.php`, and the twice-daily cron — REST is not on
144 * that list, so a fresh page load of the desktop Plugins window can
145 * see an empty/stale transient even when the dock badge (computed
146 * off `$menu`, which Core builds against `wp_get_update_data()`)
147 * reports pending updates. We mirror Core's own throttle
148 * (`wp-admin/includes/update.php::_maybe_update_plugins()` — 12h since
149 * last check) so a hot REST hit is a transient read, not an HTTPS
150 * round-trip to api.wordpress.org.
151 *
152 * Idempotent on its own (Core's 12h throttle); callers that hit this
153 * many times per request should additionally guard with their own
154 * static so they don't pay the transient-read overhead per row.
155 *
156 * @since 0.18.0
157 * @since 0.8.5 Accepts a `$force` flag — set by the in-window Refresh
158 * button via `?desktop_mode_force_refresh=1`. Bypasses
159 * the 12h throttle and runs `wp_clean_plugins_cache( true )`
160 * so the next read sees a fresh wp.org snapshot. Without
161 * this escape hatch the Refresh button was misleading:
162 * within 12h of the last check it returned the same
163 * cached "no updates" result Core had stored, while
164 * classic admin's `plugins.php` (which always calls
165 * `wp_clean_plugins_cache( true )`) showed pending updates.
166 *
167 * @param bool $force When true, delete the transient and force a fresh
168 * wp.org check regardless of the 12h throttle.
169 */
170 function desktop_mode_plugins_window_maybe_refresh_update_transient( $force = false ) {
171 /**
172 * Short-circuit the lazy refresh of the `update_plugins` transient.
173 *
174 * Return `false` to skip the refresh — useful for hosts that run
175 * their own update orchestration (managed WordPress, internal
176 * mirrors) and don't want every REST hit to the plugins endpoint
177 * to potentially trigger a wp.org check. The filter also gates the
178 * explicit force-refresh path so hosts that block wp.org calls
179 * outright keep that posture even when the user clicks Refresh.
180 *
181 * @since 0.18.0
182 * @since 0.8.5 `$force` parameter added so filter callbacks can
183 * distinguish opportunistic refreshes from explicit
184 * user-initiated ones.
185 *
186 * @param bool $refresh Whether to call `wp_update_plugins()`.
187 * @param bool $force Whether the caller asked to bypass the throttle.
188 */
189 if ( ! apply_filters( 'desktop_mode_plugins_window_refresh_updates', true, $force ) ) {
190 return;
191 }
192
193 if ( ! function_exists( 'wp_update_plugins' ) ) {
194 // `wp-includes/update.php` is normally autoloaded on every
195 // request; guard anyway so an unusual bootstrap (mu-plugin
196 // CLI harness, stripped-down REST runtime) doesn't fatal.
197 return;
198 }
199
200 if ( $force ) {
201 // Explicit user-initiated refresh — bypass the throttle.
202 // Two steps:
203 // 1. Delete the `update_plugins` site transient (and the
204 // `plugins` cache group) via `wp_clean_plugins_cache()`,
205 // OR fall back to `delete_site_transient()` directly when
206 // the admin-side helper isn't loaded.
207 // 2. Call `wp_update_plugins()` to repopulate the transient
208 // with a fresh wp.org snapshot. Without step 2 the field
209 // callback reads `false` for the rest of this request and
210 // every row reports "no updates" — that's the exact
211 // regression from the first cut of this fix (GH#202).
212 if ( function_exists( 'wp_clean_plugins_cache' ) ) {
213 wp_clean_plugins_cache( true );
214 } else {
215 delete_site_transient( 'update_plugins' );
216 }
217 wp_update_plugins();
218 return;
219 }
220
221 $current = get_site_transient( 'update_plugins' );
222 if (
223 is_object( $current ) &&
224 isset( $current->last_checked ) &&
225 12 * HOUR_IN_SECONDS > ( time() - (int) $current->last_checked )
226 ) {
227 // Inside Core's standard refresh window — trust the cached
228 // snapshot, identical to `_maybe_update_plugins()`'s posture.
229 return;
230 }
231
232 wp_update_plugins();
233 }
234
235 /**
236 * Detect whether the current REST request asked for an explicit
237 * `update_plugins` refresh via `?desktop_mode_force_refresh=1`.
238 *
239 * The flag is set by the in-window Refresh button (see
240 * `fetchInstalledPlugins({ force: true })` in `src/plugins-window/rest.ts`)
241 * and read from the query string on the way through Core's REST
242 * dispatcher. Querystring is the canonical channel — the value is an
243 * idempotent "use the slow path" hint, not a state-changing action,
244 * so no additional nonce is required beyond REST's standard
245 * `X-WP-Nonce` cookie-auth check.
246 *
247 * @since 0.8.5
248 *
249 * @return bool True when the request asked for a force-refresh.
250 */
251 function desktop_mode_plugins_window_force_refresh_requested() {
252 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately.
253 if ( ! isset( $_GET['desktop_mode_force_refresh'] ) ) {
254 return false;
255 }
256 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately.
257 $value = sanitize_text_field( wp_unslash( (string) $_GET['desktop_mode_force_refresh'] ) );
258 return '1' === $value || 'true' === $value;
259 }
260
261 /**
262 * `desktop_mode_update_available` callback.
263 *
264 * @since 0.9.0
265 *
266 * @param array $row Core REST plugin row.
267 * @return array{available:bool,new_version:string|null,package:string,slug:string}
268 */
269 function desktop_mode_plugins_window_field_update_available( $row ) {
270 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
271 if ( '' === $plugin_file ) {
272 return array(
273 'available' => false,
274 'new_version' => null,
275 'package' => '',
276 'slug' => '',
277 );
278 }
279
280 // Prime the transient once per request before reading it —
281 // otherwise REST callers see a stale/empty snapshot relative to
282 // the classic Plugins screen and the dock update badge. Static
283 // guard keeps the transient read off the hot per-row path. When
284 // the request carries `?desktop_mode_force_refresh=1` we always
285 // take the slow path so the in-window Refresh button can actually
286 // pull a fresh wp.org snapshot (the original throttle made it a
287 // no-op within 12h of the last check — see GH#202).
288 static $primed = false;
289 if ( ! $primed ) {
290 $primed = true;
291 desktop_mode_plugins_window_maybe_refresh_update_transient(
292 desktop_mode_plugins_window_force_refresh_requested()
293 );
294 }
295
296 // `update_plugins` is the canonical site-wide cache of pending
297 // updates, refreshed by `wp_update_plugins()` on the standard
298 // schedule. Reading it costs nothing.
299 $updates = get_site_transient( 'update_plugins' );
300 if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) {
301 return array(
302 'available' => false,
303 'new_version' => null,
304 'package' => '',
305 'slug' => '',
306 );
307 }
308
309 if ( ! isset( $updates->response[ $plugin_file ] ) ) {
310 return array(
311 'available' => false,
312 'new_version' => null,
313 'package' => '',
314 'slug' => '',
315 );
316 }
317
318 $entry = $updates->response[ $plugin_file ];
319 $ver = is_object( $entry ) && isset( $entry->new_version )
320 ? (string) $entry->new_version
321 : null;
322 // `package` is the download URL Core's upgrader hits to fetch the
323 // new .zip. Empty for plugins that don't ship a wp.org package
324 // (premium / private hosts) — Core renders an "Automatic update is
325 // unavailable for this plugin" notice in that case rather than the
326 // "Update now" link. We surface the URL so JS can apply the same
327 // gating without needing a second round-trip.
328 $package = is_object( $entry ) && ! empty( $entry->package )
329 ? (string) $entry->package
330 : '';
331 // `slug` is what Core's `wp_ajax_update_plugin` echoes back in its
332 // success / error envelope. We forward what the transient already
333 // carries; the AJAX handler doesn't require it on the request
334 // side (it derives slug from `plugin`), but having it client-side
335 // keeps event payloads symmetric with Core's own.
336 $slug = is_object( $entry ) && ! empty( $entry->slug )
337 ? (string) $entry->slug
338 : '';
339
340 return array(
341 'available' => true,
342 'new_version' => $ver,
343 'package' => $package,
344 'slug' => $slug,
345 );
346 }
347
348 /**
349 * `desktop_mode_can_manage` callback.
350 *
351 * Per-row cap surface so the JS UI can hide actions the viewer can't
352 * perform without re-deriving caps client-side. Server still
353 * re-validates every mutation.
354 *
355 * @since 0.9.0
356 *
357 * @param array $row Core REST plugin row.
358 * @return array{activate:bool,deactivate:bool,delete:bool}
359 */
360 function desktop_mode_plugins_window_field_can_manage( $row ) {
361 $status = isset( $row['status'] ) ? (string) $row['status'] : '';
362
363 $can_activate = current_user_can( 'activate_plugins' );
364 $can_delete = current_user_can( 'delete_plugins' );
365
366 // Active plugins can only be deleted after deactivation; surface
367 // that constraint so the JS can dim the Delete action while the
368 // row is active.
369 $can_delete_now = $can_delete && 'inactive' === $status;
370
371 return array(
372 'activate' => $can_activate && 'inactive' === $status,
373 'deactivate' => $can_activate && 'active' === $status,
374 'delete' => $can_delete_now,
375 );
376 }
377
378 /**
379 * `desktop_mode_icon_url` callback.
380 *
381 * Resolves a card icon URL for an installed plugin row, in priority:
382 *
383 * 1. **Local file** — if the plugin's own folder ships an icon at a
384 * conventional path (`assets/icon.svg`, `assets/icon-256x256.png`,
385 * `assets/icon-128x128.png`, or the same names at the folder
386 * root), return its `plugins_url()`. This is what makes premium /
387 * internal / native-bundled plugins (alcazaba-*, desktop-mode-*,
388 * and any private plugin that ships its own art) display
389 * correctly — they aren't on `ps.w.org/<slug>/`, so the wp.org
390 * candidate chain 404s through every variant before the
391 * placeholder paints.
392 * 2. **wp.org SVN asset** — `https://ps.w.org/<slug>/assets/icon.svg`,
393 * keyed off the plugin's **folder name** (which is the .org repo
394 * slug). Folder beats textdomain because the two often diverge
395 * (`woocommerce` vs textdomain `woo`, `wordpress-seo` vs
396 * `yoast-seo`). Falls back to textdomain for single-file plugins.
397 *
398 * We don't HEAD-check the URL — the JS card walks a candidate chain
399 * (SVG → 256 PNG → 256 GIF → 128 PNG → 128 GIF) on `<img>` error for wp.org URLs, then
400 * drops to a `<wpd-icon name="dashicons-admin-plugins">` placeholder.
401 * A 404 here costs nothing.
402 *
403 * @since 0.9.0
404 * @since 0.8.6 Probes the plugin's own folder for an icon before
405 * falling back to the wp.org SVN URL.
406 *
407 * @param array $row Core REST plugin row.
408 * @return string|null
409 */
410 function desktop_mode_plugins_window_field_icon_url( $row ) {
411 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
412 $folder = '' !== $plugin_file ? dirname( $plugin_file ) : '';
413 $slug = ( '' !== $folder && '.' !== $folder ) ? $folder : '';
414
415 if ( '' === $slug ) {
416 // Single-file plugin (e.g. hello.php at the plugins root) —
417 // no folder slug, so fall back to the text domain.
418 $slug = isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '';
419 }
420
421 $slug = sanitize_key( $slug );
422 if ( '' === $slug ) {
423 return null;
424 }
425
426 $default = desktop_mode_plugins_window_local_icon_url( $plugin_file );
427 if ( null === $default ) {
428 $default = 'https://ps.w.org/' . $slug . '/assets/icon.svg';
429 }
430
431 /**
432 * Filter the resolved icon URL for a plugin row.
433 *
434 * Return `null` to suppress the icon (forces the placeholder).
435 * Return a different URL to override the default — useful for
436 * custom CDN art or for overriding the auto-detected local icon.
437 *
438 * The `$url` parameter is either a local `plugins_url()` (when the
439 * plugin's own folder ships an icon at a conventional path) or the
440 * wp.org `ps.w.org/<slug>/assets/icon.svg` URL. The JS receiver
441 * walks a candidate chain on `<img>` error (`icon.svg` → 256 PNG →
442 * 128 PNG) only when the URL matches the wp.org SVN pattern;
443 * custom URLs and local URLs are one-shot, then placeholder.
444 *
445 * @since 0.9.0
446 *
447 * @param string|null $url Default URL (local file if the plugin's
448 * folder ships one, else wp.org SVG).
449 * @param string $slug Plugin slug (folder name, or textdomain
450 * for single-file plugins).
451 * @param array $row Core REST plugin row.
452 */
453 return apply_filters(
454 'desktop_mode_plugins_window_icon_url',
455 $default,
456 $slug,
457 $row
458 );
459 }
460
461 /**
462 * Probe an installed plugin's own folder for a card icon.
463 *
464 * Many premium and private plugins (and our own native extensions —
465 * alcazaba-*, desktop-mode-*) aren't on the .org repo, so the wp.org
466 * SVN URL 404s through every candidate before the placeholder paints.
467 * Most that ship art do so at a conventional location inside their
468 * own folder — typically `assets/icon.svg` mirroring the wp.org SVN
469 * /assets/ layout, occasionally bare `icon.svg` at the root for
470 * minimal plugins. We probe both shapes and return the first URL we
471 * resolve, or `null` when nothing matches.
472 *
473 * Single-file plugins (no folder) return `null` immediately — there's
474 * no folder to scan.
475 *
476 * Cost: 1–6 `file_exists()` calls per row, ~1µs each with warm OS
477 * cache. For a 50-row paint this is well under a millisecond — not
478 * worth caching, and a cache would have to invalidate on plugin
479 * install/update/delete.
480 *
481 * The candidate list is filterable via
482 * `desktop_mode_plugins_window_local_icon_candidates` so a host can
483 * support a custom convention (e.g. an `icon@2x.svg` shape).
484 *
485 * @since 0.8.6
486 *
487 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
488 * @return string|null URL of the first local icon found, or null.
489 */
490 function desktop_mode_plugins_window_local_icon_url( $plugin_file ) {
491 if ( '' === $plugin_file ) {
492 return null;
493 }
494 $folder = dirname( $plugin_file );
495 if ( '' === $folder || '.' === $folder ) {
496 // Single-file plugin — no folder to scan.
497 return null;
498 }
499
500 /**
501 * Filter the ordered list of relative paths probed inside an
502 * installed plugin's folder when looking for a card icon. The
503 * first existing file wins; later entries are ignored.
504 *
505 * @since 0.8.6
506 *
507 * @param string[] $candidates Relative paths under the plugin folder.
508 * @param string $folder Plugin folder name (e.g. `"akismet"`).
509 */
510 $candidates = apply_filters(
511 'desktop_mode_plugins_window_local_icon_candidates',
512 array(
513 'assets/icon.svg',
514 'assets/icon-256x256.png',
515 'assets/icon-128x128.png',
516 'icon.svg',
517 'icon-256x256.png',
518 'icon-128x128.png',
519 ),
520 $folder
521 );
522
523 $plugin_root = WP_PLUGIN_DIR . '/' . $folder;
524 foreach ( (array) $candidates as $relative ) {
525 $relative = (string) $relative;
526 if ( '' === $relative ) {
527 continue;
528 }
529 if ( file_exists( $plugin_root . '/' . $relative ) ) {
530 return plugins_url( $relative, WP_PLUGIN_DIR . '/' . $plugin_file );
531 }
532 }
533
534 return null;
535 }
536
537 /**
538 * `desktop_mode_size_kb` callback. Caches per-plugin for 6 hours so
539 * a 50-row table doesn't `glob`+`filesize` 50 directories on every
540 * fetch. Returns `null` when the folder can't be read.
541 *
542 * @since 0.9.0
543 *
544 * @param array $row Core REST plugin row.
545 * @return int|null Size in kilobytes, or null on failure.
546 */
547 function desktop_mode_plugins_window_field_size_kb( $row ) {
548 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
549 if ( '' === $plugin_file ) {
550 return null;
551 }
552
553 // `WP_PLUGIN_DIR` is defined in `wp-includes/default-constants.php`
554 // — safe to reference anywhere.
555 $plugin_dir = WP_PLUGIN_DIR;
556 $root = $plugin_dir . '/' . dirname( $plugin_file );
557 if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) {
558 // Single-file plugins (e.g. hello.php at the root of plugins/).
559 $candidate = $plugin_dir . '/' . $plugin_file;
560 if ( is_file( $candidate ) ) {
561 $bytes = (int) filesize( $candidate );
562 return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0;
563 }
564 return null;
565 }
566
567 $cache_key = 'dm_pwsz_' . md5( $plugin_file );
568 $cached = get_transient( $cache_key );
569 if ( false !== $cached && is_int( $cached ) ) {
570 return $cached;
571 }
572
573 $kb = desktop_mode_plugins_window_compute_dir_size_kb( $root );
574 set_transient( $cache_key, $kb, 6 * HOUR_IN_SECONDS );
575 return $kb;
576 }
577
578 /**
579 * Recursively sum file sizes under `$dir`, returning kilobytes.
580 *
581 * Caps total iteration to 5,000 entries so a pathological symlink
582 * loop (or an enormous plugin folder full of vendor cruft) can't
583 * stall a REST response. When the cap trips we return whatever we
584 * counted so far — a slight under-report is better than a hung
585 * request.
586 *
587 * @since 0.9.0
588 *
589 * @param string $dir Absolute filesystem path.
590 * @return int Kilobytes (rounded).
591 */
592 function desktop_mode_plugins_window_compute_dir_size_kb( $dir ) {
593 if ( ! is_dir( $dir ) ) {
594 return 0;
595 }
596
597 $total_bytes = 0;
598 $visited = 0;
599 $max_visit = 5000;
600
601 $stack = array( $dir );
602 while ( ! empty( $stack ) && $visited < $max_visit ) {
603 $current = array_pop( $stack );
604 $entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null.
605 if ( ! is_array( $entries ) ) {
606 continue;
607 }
608 foreach ( $entries as $entry ) {
609 if ( '.' === $entry || '..' === $entry ) {
610 continue;
611 }
612 $path = $current . '/' . $entry;
613 if ( is_link( $path ) ) {
614 // Skip symlinks: they could escape the plugin folder
615 // or recurse infinitely. The classic admin's plugin
616 // list ignores symlink contents for the same reason.
617 continue;
618 }
619 $visited++;
620 if ( $visited >= $max_visit ) {
621 break 2;
622 }
623 if ( is_dir( $path ) ) {
624 $stack[] = $path;
625 } elseif ( is_file( $path ) ) {
626 $total_bytes += (int) filesize( $path );
627 }
628 }
629 }
630
631 return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0;
632 }
633
634 /**
635 * `desktop_mode_auto_update` callback.
636 *
637 * Mirrors the per-row state Core derives in
638 * `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates"
639 * column. Shape:
640 *
641 * - `enabled` bool — the plugin file is currently in the
642 * `auto_update_plugins` site option, OR a
643 * filter has forced auto-updates on.
644 * - `forced` bool|null — `true`/`false` when the
645 * `auto_update_plugin` filter pinned the state,
646 * `null` when the user is free to toggle.
647 * - `supported` bool — whether the `update_plugins` transient has an
648 * entry for this plugin (either in `response` or
649 * `no_update`). Core hides the toggle entirely
650 * when this is false — premium / private plugins
651 * that never check in with wp.org.
652 *
653 * NOT included here (lives on the window config instead): the global
654 * `wp_is_auto_update_enabled_for_type( 'plugin' )` flag, which depends
655 * on admin-only includes — see `desktop_mode_plugins_window_auto_updates_enabled()`.
656 *
657 * @since 0.21.0
658 *
659 * @param array $row Core REST plugin row.
660 * @return array{enabled:bool,forced:bool|null,supported:bool}
661 */
662 function desktop_mode_plugins_window_field_auto_update( $row ) {
663 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
664 if ( '' === $plugin_file ) {
665 return array(
666 'enabled' => false,
667 'forced' => null,
668 'supported' => false,
669 );
670 }
671
672 $auto_updates = (array) get_site_option( 'auto_update_plugins', array() );
673 $enabled = in_array( $plugin_file, $auto_updates, true );
674
675 // `update-supported` mirrors Core's logic: a plugin is "supported"
676 // for auto-update toggling when wp.org has either a pending update
677 // row OR an explicit no-update row in the `update_plugins` transient.
678 // Premium / private plugins that never call home land in neither
679 // bucket — Core hides the toggle so the user doesn't enable an
680 // auto-update that can't ever fire.
681 $supported = false;
682 $updates = get_site_transient( 'update_plugins' );
683 if ( is_object( $updates ) ) {
684 if ( isset( $updates->response[ $plugin_file ] ) || isset( $updates->no_update[ $plugin_file ] ) ) {
685 $supported = true;
686 }
687 }
688
689 // Build the payload Core's filter expects (mirrors
690 // `WP_Plugins_List_Table::prepare_items()`'s `$filter_payload`).
691 // `wp_is_auto_update_forced_for_item()` itself is in
692 // `wp-admin/includes/update.php` — we can't include that from a REST
693 // callback (Plugin Check), so we run the filter directly. It's a
694 // single `apply_filters()` call under the hood.
695 //
696 // Important: `wp_parse_args( $row, $defaults )` lets `$row` keys
697 // override `$defaults`. Core's REST controller strips `.php` from
698 // the `plugin` field, but every filter that hooks `auto_update_plugin`
699 // (including Core's own) reads `$item->plugin` expecting the FULL
700 // filename. We layer the normalized `$plugin_file` AFTER the parse
701 // so it always wins.
702 $filter_payload = wp_parse_args(
703 $row,
704 array(
705 'id' => $plugin_file,
706 'slug' => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '',
707 'plugin' => $plugin_file,
708 'new_version' => '',
709 'url' => '',
710 'package' => '',
711 'icons' => array(),
712 'banners' => array(),
713 'banners_rtl' => array(),
714 'tested' => '',
715 'requires_php' => '',
716 'compatibility' => new stdClass(),
717 )
718 );
719 $filter_payload['plugin'] = $plugin_file;
720 $filter_payload['id'] = $plugin_file;
721 $filter_payload = (object) $filter_payload;
722 /** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */
723 $forced = apply_filters( 'auto_update_plugin', null, $filter_payload );
724 if ( null !== $forced ) {
725 $forced = (bool) $forced;
726 // When a filter forces the state, that's the effective state
727 // regardless of the `auto_update_plugins` option — match Core's
728 // rendering in `single_row_columns()`.
729 $enabled = $forced;
730 }
731
732 return array(
733 'enabled' => (bool) $enabled,
734 'forced' => $forced,
735 'supported' => $supported,
736 );
737 }
738