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

784 lines 28.2 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 * Count plugin updates visible to the Plugins window — i.e. updates in
350 * the `update_plugins` site transient whose key corresponds to an
351 * actually-installed plugin file (`get_plugins()`).
352 *
353 * Core's `wp_get_update_data()` reports `count( $update_plugins->response )`
354 * verbatim, which is what `wp-admin/menu.php` embeds in the Plugins
355 * menu title (the source the dock-builder regex captures). That raw
356 * count can drift above the in-window "Update available" filter when
357 * the transient holds orphan entries — rows for plugin files that no
358 * longer exist on disk, or rows injected via the standard `Update URI`
359 * mechanism that key on a file `get_plugins()` doesn't return.
360 *
361 * The Plugins window iterates `get_plugins()` via REST and shows each
362 * row as updatable iff `update_plugins->response[ $plugin_file ]` is
363 * set — exactly the intersection we compute here. Using this count for
364 * the dock badge guarantees the two surfaces agree (GH#258).
365 *
366 * @since 0.8.8
367 *
368 * @return int Number of installed plugins with a pending update.
369 */
370 function desktop_mode_plugins_window_count_visible_updates() {
371 $updates = get_site_transient( 'update_plugins' );
372 if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) {
373 return 0;
374 }
375
376 // `get_plugins()` lives in `wp-admin/includes/plugin.php`. Loaded by
377 // default on every admin request (which is where `$menu` is built),
378 // but require it explicitly so REST + cron + WP-CLI callers can use
379 // this helper without depending on the admin runtime.
380 if ( ! function_exists( 'get_plugins' ) ) {
381 require_once ABSPATH . 'wp-admin/includes/plugin.php';
382 }
383 $installed = get_plugins();
384
385 $count = 0;
386 foreach ( array_keys( $updates->response ) as $plugin_file ) {
387 if ( isset( $installed[ $plugin_file ] ) ) {
388 ++$count;
389 }
390 }
391 return $count;
392 }
393
394 /**
395 * `desktop_mode_can_manage` callback.
396 *
397 * Per-row cap surface so the JS UI can hide actions the viewer can't
398 * perform without re-deriving caps client-side. Server still
399 * re-validates every mutation.
400 *
401 * @since 0.9.0
402 *
403 * @param array $row Core REST plugin row.
404 * @return array{activate:bool,deactivate:bool,delete:bool}
405 */
406 function desktop_mode_plugins_window_field_can_manage( $row ) {
407 $status = isset( $row['status'] ) ? (string) $row['status'] : '';
408
409 $can_activate = current_user_can( 'activate_plugins' );
410 $can_delete = current_user_can( 'delete_plugins' );
411
412 // Active plugins can only be deleted after deactivation; surface
413 // that constraint so the JS can dim the Delete action while the
414 // row is active.
415 $can_delete_now = $can_delete && 'inactive' === $status;
416
417 return array(
418 'activate' => $can_activate && 'inactive' === $status,
419 'deactivate' => $can_activate && 'active' === $status,
420 'delete' => $can_delete_now,
421 );
422 }
423
424 /**
425 * `desktop_mode_icon_url` callback.
426 *
427 * Resolves a card icon URL for an installed plugin row, in priority:
428 *
429 * 1. **Local file** — if the plugin's own folder ships an icon at a
430 * conventional path (`assets/icon.svg`, `assets/icon-256x256.png`,
431 * `assets/icon-128x128.png`, or the same names at the folder
432 * root), return its `plugins_url()`. This is what makes premium /
433 * internal / native-bundled plugins (alcazaba-*, desktop-mode-*,
434 * and any private plugin that ships its own art) display
435 * correctly — they aren't on `ps.w.org/<slug>/`, so the wp.org
436 * candidate chain 404s through every variant before the
437 * placeholder paints.
438 * 2. **wp.org SVN asset** — `https://ps.w.org/<slug>/assets/icon.svg`,
439 * keyed off the plugin's **folder name** (which is the .org repo
440 * slug). Folder beats textdomain because the two often diverge
441 * (`woocommerce` vs textdomain `woo`, `wordpress-seo` vs
442 * `yoast-seo`). Falls back to textdomain for single-file plugins.
443 *
444 * We don't HEAD-check the URL — the JS card walks a candidate chain
445 * (SVG → 256 PNG → 256 GIF → 128 PNG → 128 GIF) on `<img>` error for wp.org URLs, then
446 * drops to a `<wpd-icon name="dashicons-admin-plugins">` placeholder.
447 * A 404 here costs nothing.
448 *
449 * @since 0.9.0
450 * @since 0.8.6 Probes the plugin's own folder for an icon before
451 * falling back to the wp.org SVN URL.
452 *
453 * @param array $row Core REST plugin row.
454 * @return string|null
455 */
456 function desktop_mode_plugins_window_field_icon_url( $row ) {
457 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
458 $folder = '' !== $plugin_file ? dirname( $plugin_file ) : '';
459 $slug = ( '' !== $folder && '.' !== $folder ) ? $folder : '';
460
461 if ( '' === $slug ) {
462 // Single-file plugin (e.g. hello.php at the plugins root) —
463 // no folder slug, so fall back to the text domain.
464 $slug = isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '';
465 }
466
467 $slug = sanitize_key( $slug );
468 if ( '' === $slug ) {
469 return null;
470 }
471
472 $default = desktop_mode_plugins_window_local_icon_url( $plugin_file );
473 if ( null === $default ) {
474 $default = 'https://ps.w.org/' . $slug . '/assets/icon.svg';
475 }
476
477 /**
478 * Filter the resolved icon URL for a plugin row.
479 *
480 * Return `null` to suppress the icon (forces the placeholder).
481 * Return a different URL to override the default — useful for
482 * custom CDN art or for overriding the auto-detected local icon.
483 *
484 * The `$url` parameter is either a local `plugins_url()` (when the
485 * plugin's own folder ships an icon at a conventional path) or the
486 * wp.org `ps.w.org/<slug>/assets/icon.svg` URL. The JS receiver
487 * walks a candidate chain on `<img>` error (`icon.svg` → 256 PNG →
488 * 128 PNG) only when the URL matches the wp.org SVN pattern;
489 * custom URLs and local URLs are one-shot, then placeholder.
490 *
491 * @since 0.9.0
492 *
493 * @param string|null $url Default URL (local file if the plugin's
494 * folder ships one, else wp.org SVG).
495 * @param string $slug Plugin slug (folder name, or textdomain
496 * for single-file plugins).
497 * @param array $row Core REST plugin row.
498 */
499 return apply_filters(
500 'desktop_mode_plugins_window_icon_url',
501 $default,
502 $slug,
503 $row
504 );
505 }
506
507 /**
508 * Probe an installed plugin's own folder for a card icon.
509 *
510 * Many premium and private plugins (and our own native extensions —
511 * alcazaba-*, desktop-mode-*) aren't on the .org repo, so the wp.org
512 * SVN URL 404s through every candidate before the placeholder paints.
513 * Most that ship art do so at a conventional location inside their
514 * own folder — typically `assets/icon.svg` mirroring the wp.org SVN
515 * /assets/ layout, occasionally bare `icon.svg` at the root for
516 * minimal plugins. We probe both shapes and return the first URL we
517 * resolve, or `null` when nothing matches.
518 *
519 * Single-file plugins (no folder) return `null` immediately — there's
520 * no folder to scan.
521 *
522 * Cost: 1–6 `file_exists()` calls per row, ~1µs each with warm OS
523 * cache. For a 50-row paint this is well under a millisecond — not
524 * worth caching, and a cache would have to invalidate on plugin
525 * install/update/delete.
526 *
527 * The candidate list is filterable via
528 * `desktop_mode_plugins_window_local_icon_candidates` so a host can
529 * support a custom convention (e.g. an `icon@2x.svg` shape).
530 *
531 * @since 0.8.6
532 *
533 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
534 * @return string|null URL of the first local icon found, or null.
535 */
536 function desktop_mode_plugins_window_local_icon_url( $plugin_file ) {
537 if ( '' === $plugin_file ) {
538 return null;
539 }
540 $folder = dirname( $plugin_file );
541 if ( '' === $folder || '.' === $folder ) {
542 // Single-file plugin — no folder to scan.
543 return null;
544 }
545
546 /**
547 * Filter the ordered list of relative paths probed inside an
548 * installed plugin's folder when looking for a card icon. The
549 * first existing file wins; later entries are ignored.
550 *
551 * @since 0.8.6
552 *
553 * @param string[] $candidates Relative paths under the plugin folder.
554 * @param string $folder Plugin folder name (e.g. `"akismet"`).
555 */
556 $candidates = apply_filters(
557 'desktop_mode_plugins_window_local_icon_candidates',
558 array(
559 'assets/icon.svg',
560 'assets/icon-256x256.png',
561 'assets/icon-128x128.png',
562 'icon.svg',
563 'icon-256x256.png',
564 'icon-128x128.png',
565 ),
566 $folder
567 );
568
569 $plugin_root = WP_PLUGIN_DIR . '/' . $folder;
570 foreach ( (array) $candidates as $relative ) {
571 $relative = (string) $relative;
572 if ( '' === $relative ) {
573 continue;
574 }
575 if ( file_exists( $plugin_root . '/' . $relative ) ) {
576 return plugins_url( $relative, WP_PLUGIN_DIR . '/' . $plugin_file );
577 }
578 }
579
580 return null;
581 }
582
583 /**
584 * `desktop_mode_size_kb` callback. Caches per-plugin for 6 hours so
585 * a 50-row table doesn't `glob`+`filesize` 50 directories on every
586 * fetch. Returns `null` when the folder can't be read.
587 *
588 * @since 0.9.0
589 *
590 * @param array $row Core REST plugin row.
591 * @return int|null Size in kilobytes, or null on failure.
592 */
593 function desktop_mode_plugins_window_field_size_kb( $row ) {
594 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
595 if ( '' === $plugin_file ) {
596 return null;
597 }
598
599 // `WP_PLUGIN_DIR` is defined in `wp-includes/default-constants.php`
600 // — safe to reference anywhere.
601 $plugin_dir = WP_PLUGIN_DIR;
602 $root = $plugin_dir . '/' . dirname( $plugin_file );
603 if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) {
604 // Single-file plugins (e.g. hello.php at the root of plugins/).
605 $candidate = $plugin_dir . '/' . $plugin_file;
606 if ( is_file( $candidate ) ) {
607 $bytes = (int) filesize( $candidate );
608 return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0;
609 }
610 return null;
611 }
612
613 $cache_key = 'dm_pwsz_' . md5( $plugin_file );
614 $cached = get_transient( $cache_key );
615 if ( false !== $cached && is_int( $cached ) ) {
616 return $cached;
617 }
618
619 $kb = desktop_mode_plugins_window_compute_dir_size_kb( $root );
620 set_transient( $cache_key, $kb, 6 * HOUR_IN_SECONDS );
621 return $kb;
622 }
623
624 /**
625 * Recursively sum file sizes under `$dir`, returning kilobytes.
626 *
627 * Caps total iteration to 5,000 entries so a pathological symlink
628 * loop (or an enormous plugin folder full of vendor cruft) can't
629 * stall a REST response. When the cap trips we return whatever we
630 * counted so far — a slight under-report is better than a hung
631 * request.
632 *
633 * @since 0.9.0
634 *
635 * @param string $dir Absolute filesystem path.
636 * @return int Kilobytes (rounded).
637 */
638 function desktop_mode_plugins_window_compute_dir_size_kb( $dir ) {
639 if ( ! is_dir( $dir ) ) {
640 return 0;
641 }
642
643 $total_bytes = 0;
644 $visited = 0;
645 $max_visit = 5000;
646
647 $stack = array( $dir );
648 while ( ! empty( $stack ) && $visited < $max_visit ) {
649 $current = array_pop( $stack );
650 $entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null.
651 if ( ! is_array( $entries ) ) {
652 continue;
653 }
654 foreach ( $entries as $entry ) {
655 if ( '.' === $entry || '..' === $entry ) {
656 continue;
657 }
658 $path = $current . '/' . $entry;
659 if ( is_link( $path ) ) {
660 // Skip symlinks: they could escape the plugin folder
661 // or recurse infinitely. The classic admin's plugin
662 // list ignores symlink contents for the same reason.
663 continue;
664 }
665 $visited++;
666 if ( $visited >= $max_visit ) {
667 break 2;
668 }
669 if ( is_dir( $path ) ) {
670 $stack[] = $path;
671 } elseif ( is_file( $path ) ) {
672 $total_bytes += (int) filesize( $path );
673 }
674 }
675 }
676
677 return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0;
678 }
679
680 /**
681 * `desktop_mode_auto_update` callback.
682 *
683 * Mirrors the per-row state Core derives in
684 * `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates"
685 * column. Shape:
686 *
687 * - `enabled` bool — the plugin file is currently in the
688 * `auto_update_plugins` site option, OR a
689 * filter has forced auto-updates on.
690 * - `forced` bool|null — `true`/`false` when the
691 * `auto_update_plugin` filter pinned the state,
692 * `null` when the user is free to toggle.
693 * - `supported` bool — whether the `update_plugins` transient has an
694 * entry for this plugin (either in `response` or
695 * `no_update`). Core hides the toggle entirely
696 * when this is false — premium / private plugins
697 * that never check in with wp.org.
698 *
699 * NOT included here (lives on the window config instead): the global
700 * `wp_is_auto_update_enabled_for_type( 'plugin' )` flag, which depends
701 * on admin-only includes — see `desktop_mode_plugins_window_auto_updates_enabled()`.
702 *
703 * @since 0.21.0
704 *
705 * @param array $row Core REST plugin row.
706 * @return array{enabled:bool,forced:bool|null,supported:bool}
707 */
708 function desktop_mode_plugins_window_field_auto_update( $row ) {
709 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
710 if ( '' === $plugin_file ) {
711 return array(
712 'enabled' => false,
713 'forced' => null,
714 'supported' => false,
715 );
716 }
717
718 $auto_updates = (array) get_site_option( 'auto_update_plugins', array() );
719 $enabled = in_array( $plugin_file, $auto_updates, true );
720
721 // `update-supported` mirrors Core's logic: a plugin is "supported"
722 // for auto-update toggling when wp.org has either a pending update
723 // row OR an explicit no-update row in the `update_plugins` transient.
724 // Premium / private plugins that never call home land in neither
725 // bucket — Core hides the toggle so the user doesn't enable an
726 // auto-update that can't ever fire.
727 $supported = false;
728 $updates = get_site_transient( 'update_plugins' );
729 if ( is_object( $updates ) ) {
730 if ( isset( $updates->response[ $plugin_file ] ) || isset( $updates->no_update[ $plugin_file ] ) ) {
731 $supported = true;
732 }
733 }
734
735 // Build the payload Core's filter expects (mirrors
736 // `WP_Plugins_List_Table::prepare_items()`'s `$filter_payload`).
737 // `wp_is_auto_update_forced_for_item()` itself is in
738 // `wp-admin/includes/update.php` — we can't include that from a REST
739 // callback (Plugin Check), so we run the filter directly. It's a
740 // single `apply_filters()` call under the hood.
741 //
742 // Important: `wp_parse_args( $row, $defaults )` lets `$row` keys
743 // override `$defaults`. Core's REST controller strips `.php` from
744 // the `plugin` field, but every filter that hooks `auto_update_plugin`
745 // (including Core's own) reads `$item->plugin` expecting the FULL
746 // filename. We layer the normalized `$plugin_file` AFTER the parse
747 // so it always wins.
748 $filter_payload = wp_parse_args(
749 $row,
750 array(
751 'id' => $plugin_file,
752 'slug' => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '',
753 'plugin' => $plugin_file,
754 'new_version' => '',
755 'url' => '',
756 'package' => '',
757 'icons' => array(),
758 'banners' => array(),
759 'banners_rtl' => array(),
760 'tested' => '',
761 'requires_php' => '',
762 'compatibility' => new stdClass(),
763 )
764 );
765 $filter_payload['plugin'] = $plugin_file;
766 $filter_payload['id'] = $plugin_file;
767 $filter_payload = (object) $filter_payload;
768 /** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */
769 $forced = apply_filters( 'auto_update_plugin', null, $filter_payload );
770 if ( null !== $forced ) {
771 $forced = (bool) $forced;
772 // When a filter forces the state, that's the effective state
773 // regardless of the `auto_update_plugins` option — match Core's
774 // rendering in `single_row_columns()`.
775 $enabled = $forced;
776 }
777
778 return array(
779 'enabled' => (bool) $enabled,
780 'forced' => $forced,
781 'supported' => $supported,
782 );
783 }
784