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

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