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

785 lines 28.3 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.8.6 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.8.3
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.8.3
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.8.3
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 * 256 GIF → 128 PNG → 128 GIF) only when the URL matches the
489 * wp.org SVN pattern;
490 * custom URLs and local URLs are one-shot, then placeholder.
491 *
492 * @since 0.9.0
493 *
494 * @param string|null $url Default URL (local file if the plugin's
495 * folder ships one, else wp.org SVG).
496 * @param string $slug Plugin slug (folder name, or textdomain
497 * for single-file plugins).
498 * @param array $row Core REST plugin row.
499 */
500 return apply_filters(
501 'desktop_mode_plugins_window_icon_url',
502 $default,
503 $slug,
504 $row
505 );
506 }
507
508 /**
509 * Probe an installed plugin's own folder for a card icon.
510 *
511 * Many premium and private plugins (and our own native extensions —
512 * alcazaba-*, desktop-mode-*) aren't on the .org repo, so the wp.org
513 * SVN URL 404s through every candidate before the placeholder paints.
514 * Most that ship art do so at a conventional location inside their
515 * own folder — typically `assets/icon.svg` mirroring the wp.org SVN
516 * /assets/ layout, occasionally bare `icon.svg` at the root for
517 * minimal plugins. We probe both shapes and return the first URL we
518 * resolve, or `null` when nothing matches.
519 *
520 * Single-file plugins (no folder) return `null` immediately — there's
521 * no folder to scan.
522 *
523 * Cost: 1–6 `file_exists()` calls per row, ~1µs each with warm OS
524 * cache. For a 50-row paint this is well under a millisecond — not
525 * worth caching, and a cache would have to invalidate on plugin
526 * install/update/delete.
527 *
528 * The candidate list is filterable via
529 * `desktop_mode_plugins_window_local_icon_candidates` so a host can
530 * support a custom convention (e.g. an `icon@2x.svg` shape).
531 *
532 * @since 0.8.6
533 *
534 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
535 * @return string|null URL of the first local icon found, or null.
536 */
537 function desktop_mode_plugins_window_local_icon_url( $plugin_file ) {
538 if ( '' === $plugin_file ) {
539 return null;
540 }
541 $folder = dirname( $plugin_file );
542 if ( '' === $folder || '.' === $folder ) {
543 // Single-file plugin — no folder to scan.
544 return null;
545 }
546
547 /**
548 * Filter the ordered list of relative paths probed inside an
549 * installed plugin's folder when looking for a card icon. The
550 * first existing file wins; later entries are ignored.
551 *
552 * @since 0.8.6
553 *
554 * @param string[] $candidates Relative paths under the plugin folder.
555 * @param string $folder Plugin folder name (e.g. `"akismet"`).
556 */
557 $candidates = apply_filters(
558 'desktop_mode_plugins_window_local_icon_candidates',
559 array(
560 'assets/icon.svg',
561 'assets/icon-256x256.png',
562 'assets/icon-128x128.png',
563 'icon.svg',
564 'icon-256x256.png',
565 'icon-128x128.png',
566 ),
567 $folder
568 );
569
570 $plugin_root = WP_PLUGIN_DIR . '/' . $folder;
571 foreach ( (array) $candidates as $relative ) {
572 $relative = (string) $relative;
573 if ( '' === $relative ) {
574 continue;
575 }
576 if ( file_exists( $plugin_root . '/' . $relative ) ) {
577 return plugins_url( $relative, WP_PLUGIN_DIR . '/' . $plugin_file );
578 }
579 }
580
581 return null;
582 }
583
584 /**
585 * `desktop_mode_size_kb` callback. Caches per-plugin for 6 hours so
586 * a 50-row table doesn't `glob`+`filesize` 50 directories on every
587 * fetch. Returns `null` when the folder can't be read.
588 *
589 * @since 0.9.0
590 *
591 * @param array $row Core REST plugin row.
592 * @return int|null Size in kilobytes, or null on failure.
593 */
594 function desktop_mode_plugins_window_field_size_kb( $row ) {
595 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
596 if ( '' === $plugin_file ) {
597 return null;
598 }
599
600 // `WP_PLUGIN_DIR` is defined in `wp-includes/default-constants.php`
601 // — safe to reference anywhere.
602 $plugin_dir = WP_PLUGIN_DIR;
603 $root = $plugin_dir . '/' . dirname( $plugin_file );
604 if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) {
605 // Single-file plugins (e.g. hello.php at the root of plugins/).
606 $candidate = $plugin_dir . '/' . $plugin_file;
607 if ( is_file( $candidate ) ) {
608 $bytes = (int) filesize( $candidate );
609 return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0;
610 }
611 return null;
612 }
613
614 $cache_key = 'dm_pwsz_' . md5( $plugin_file );
615 $cached = get_transient( $cache_key );
616 if ( false !== $cached && is_int( $cached ) ) {
617 return $cached;
618 }
619
620 $kb = desktop_mode_plugins_window_compute_dir_size_kb( $root );
621 set_transient( $cache_key, $kb, 6 * HOUR_IN_SECONDS );
622 return $kb;
623 }
624
625 /**
626 * Recursively sum file sizes under `$dir`, returning kilobytes.
627 *
628 * Caps total iteration to 5,000 entries so a pathological symlink
629 * loop (or an enormous plugin folder full of vendor cruft) can't
630 * stall a REST response. When the cap trips we return whatever we
631 * counted so far — a slight under-report is better than a hung
632 * request.
633 *
634 * @since 0.9.0
635 *
636 * @param string $dir Absolute filesystem path.
637 * @return int Kilobytes (rounded).
638 */
639 function desktop_mode_plugins_window_compute_dir_size_kb( $dir ) {
640 if ( ! is_dir( $dir ) ) {
641 return 0;
642 }
643
644 $total_bytes = 0;
645 $visited = 0;
646 $max_visit = 5000;
647
648 $stack = array( $dir );
649 while ( ! empty( $stack ) && $visited < $max_visit ) {
650 $current = array_pop( $stack );
651 $entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null.
652 if ( ! is_array( $entries ) ) {
653 continue;
654 }
655 foreach ( $entries as $entry ) {
656 if ( '.' === $entry || '..' === $entry ) {
657 continue;
658 }
659 $path = $current . '/' . $entry;
660 if ( is_link( $path ) ) {
661 // Skip symlinks: they could escape the plugin folder
662 // or recurse infinitely. The classic admin's plugin
663 // list ignores symlink contents for the same reason.
664 continue;
665 }
666 $visited++;
667 if ( $visited >= $max_visit ) {
668 break 2;
669 }
670 if ( is_dir( $path ) ) {
671 $stack[] = $path;
672 } elseif ( is_file( $path ) ) {
673 $total_bytes += (int) filesize( $path );
674 }
675 }
676 }
677
678 return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0;
679 }
680
681 /**
682 * `desktop_mode_auto_update` callback.
683 *
684 * Mirrors the per-row state Core derives in
685 * `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates"
686 * column. Shape:
687 *
688 * - `enabled` bool — the plugin file is currently in the
689 * `auto_update_plugins` site option, OR a
690 * filter has forced auto-updates on.
691 * - `forced` bool|null — `true`/`false` when the
692 * `auto_update_plugin` filter pinned the state,
693 * `null` when the user is free to toggle.
694 * - `supported` bool — whether the `update_plugins` transient has an
695 * entry for this plugin (either in `response` or
696 * `no_update`). Core hides the toggle entirely
697 * when this is false — premium / private plugins
698 * that never check in with wp.org.
699 *
700 * NOT included here (lives on the window config instead): the global
701 * `wp_is_auto_update_enabled_for_type( 'plugin' )` flag, which depends
702 * on admin-only includes — see `desktop_mode_plugins_window_auto_updates_enabled()`.
703 *
704 * @since 0.8.6
705 *
706 * @param array $row Core REST plugin row.
707 * @return array{enabled:bool,forced:bool|null,supported:bool}
708 */
709 function desktop_mode_plugins_window_field_auto_update( $row ) {
710 $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row );
711 if ( '' === $plugin_file ) {
712 return array(
713 'enabled' => false,
714 'forced' => null,
715 'supported' => false,
716 );
717 }
718
719 $auto_updates = (array) get_site_option( 'auto_update_plugins', array() );
720 $enabled = in_array( $plugin_file, $auto_updates, true );
721
722 // `update-supported` mirrors Core's logic: a plugin is "supported"
723 // for auto-update toggling when wp.org has either a pending update
724 // row OR an explicit no-update row in the `update_plugins` transient.
725 // Premium / private plugins that never call home land in neither
726 // bucket — Core hides the toggle so the user doesn't enable an
727 // auto-update that can't ever fire.
728 $supported = false;
729 $updates = get_site_transient( 'update_plugins' );
730 if ( is_object( $updates ) ) {
731 if ( isset( $updates->response[ $plugin_file ] ) || isset( $updates->no_update[ $plugin_file ] ) ) {
732 $supported = true;
733 }
734 }
735
736 // Build the payload Core's filter expects (mirrors
737 // `WP_Plugins_List_Table::prepare_items()`'s `$filter_payload`).
738 // `wp_is_auto_update_forced_for_item()` itself is in
739 // `wp-admin/includes/update.php` — we can't include that from a REST
740 // callback (Plugin Check), so we run the filter directly. It's a
741 // single `apply_filters()` call under the hood.
742 //
743 // Important: `wp_parse_args( $row, $defaults )` lets `$row` keys
744 // override `$defaults`. Core's REST controller strips `.php` from
745 // the `plugin` field, but every filter that hooks `auto_update_plugin`
746 // (including Core's own) reads `$item->plugin` expecting the FULL
747 // filename. We layer the normalized `$plugin_file` AFTER the parse
748 // so it always wins.
749 $filter_payload = wp_parse_args(
750 $row,
751 array(
752 'id' => $plugin_file,
753 'slug' => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '',
754 'plugin' => $plugin_file,
755 'new_version' => '',
756 'url' => '',
757 'package' => '',
758 'icons' => array(),
759 'banners' => array(),
760 'banners_rtl' => array(),
761 'tested' => '',
762 'requires_php' => '',
763 'compatibility' => new stdClass(),
764 )
765 );
766 $filter_payload['plugin'] = $plugin_file;
767 $filter_payload['id'] = $plugin_file;
768 $filter_payload = (object) $filter_payload;
769 /** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */
770 $forced = apply_filters( 'auto_update_plugin', null, $filter_payload );
771 if ( null !== $forced ) {
772 $forced = (bool) $forced;
773 // When a filter forces the state, that's the effective state
774 // regardless of the `auto_update_plugins` option — match Core's
775 // rendering in `single_row_columns()`.
776 $enabled = $forced;
777 }
778
779 return array(
780 'enabled' => (bool) $enabled,
781 'forced' => $forced,
782 'supported' => $supported,
783 );
784 }
785