# desktop-mode/1.1.3/includes/plugins-window/rest-fields.php

OpenStation: Desktop Windows, Dock &amp; Virtual Desktops for WP Admin, version 1.1.3. 889 lines.

- Page: https://pluginprobe.com/plugins/desktop-mode/1.1.3/code/includes/plugins-window/rest-fields.php
- Raw: https://pluginprobe.com/plugins/desktop-mode/1.1.3/raw/includes/plugins-window/rest-fields.php
- Modified: 2026-08-24T15:46:58+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/desktop-mode/1.1.3/code/includes/plugins-window/rest-fields.php#L10-L20`.

```php
<?php
/**
 * OpenStation — Native Plugins Window: REST field decorators.
 *
 * Adds enrichment fields to Core's `/wp/v2/plugins` REST resource so
 * the JS bundle can render rich rows in one round-trip:
 *
 *   - openstation_update_available — `{ available, new_version }`
 *   - openstation_can_manage       — `{ activate, deactivate, delete }`
 *   - openstation_wporg_slug       — .org directory slug, or null when not listed
 *   - openstation_icon_url         — local-folder icon, falling back to wp.org
 *   - openstation_size_kb          — disk size of plugin folder
 *   - openstation_auto_update      — `{ enabled, forced, supported }`
 *
 * Plugin Check posture: every callback below uses ONLY functions
 * available in `wp-includes/` (current_user_can, get_site_transient,
 * filesize, glob, …). No admin-only includes are needed, so REST is
 * the right home — registering these fields on `rest_api_init` keeps
 * the contract consistent with Core's other plugin REST decorators.
 *
 * @package OpenStation
 */

defined( 'ABSPATH' ) || exit;

/**
 * Register the six enrichment fields on the `plugin` REST resource.
 */
function openstation_plugins_window_register_rest_fields() {
	register_rest_field(
		'plugin',
		'openstation_update_available',
		array(
			'get_callback' => 'openstation_plugins_window_field_update_available',
			'schema'       => array(
				'description' => __( 'Whether an update is available for this plugin (and the available version).', 'desktop-mode' ),
				'type'        => 'object',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		)
	);

	register_rest_field(
		'plugin',
		'openstation_can_manage',
		array(
			'get_callback' => 'openstation_plugins_window_field_can_manage',
			'schema'       => array(
				'description' => __( 'Per-plugin capability flags for the requester (activate / deactivate / delete).', 'desktop-mode' ),
				'type'        => 'object',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		)
	);

	register_rest_field(
		'plugin',
		'openstation_wporg_slug',
		array(
			'get_callback' => 'openstation_plugins_window_field_wporg_slug',
			'schema'       => array(
				'description' => __( 'The plugin\'s slug on the WordPress.org directory, or null when the plugin is not listed there.', 'desktop-mode' ),
				'type'        => array( 'string', 'null' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		)
	);

	register_rest_field(
		'plugin',
		'openstation_icon_url',
		array(
			'get_callback' => 'openstation_plugins_window_field_icon_url',
			'schema'       => array(
				'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' ),
				'type'        => array( 'string', 'null' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		)
	);

	register_rest_field(
		'plugin',
		'openstation_size_kb',
		array(
			'get_callback' => 'openstation_plugins_window_field_size_kb',
			'schema'       => array(
				'description' => __( 'Approximate disk footprint of the plugin folder, in kilobytes (cached 6h).', 'desktop-mode' ),
				'type'        => array( 'integer', 'null' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		)
	);

	register_rest_field(
		'plugin',
		'openstation_auto_update',
		array(
			'get_callback' => 'openstation_plugins_window_field_auto_update',
			'schema'       => array(
				'description' => __( 'Auto-update state for this plugin (enabled / forced / supported), mirroring Core\'s plugins.php column.', 'desktop-mode' ),
				'type'        => 'object',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		)
	);
}
add_action( 'rest_api_init', 'openstation_plugins_window_register_rest_fields' );

/**
 * Resolve the plugin file path (relative to `WP_PLUGIN_DIR`, ending in
 * `.php`) for a Core REST plugin row.
 *
 * Core's `WP_REST_Plugins_Controller::prepare_item_for_response` emits
 * the `plugin` field with the trailing `.php` STRIPPED — e.g.
 * `"elementor/elementor"` rather than `"elementor/elementor.php"`. But
 * every internal WordPress data structure that keys off the plugin
 * file — `update_plugins` site transient, `active_plugins` option,
 * `plugin_basename()`, `WP_PLUGIN_DIR` paths — uses the full filename.
 * Mixing the two yields silent lookup misses (the symptom that hid
 * the "Update available" tab).
 *
 * This helper re-appends `.php` when missing so callers can use the
 * result as a transient/option key or filesystem path directly.
 *
 * @param array $row Core REST plugin row.
 * @return string Plugin file (e.g. `"elementor/elementor.php"`), or `''`
 *                when the row has no `plugin` field.
 */
function openstation_plugins_window_row_plugin_file( $row ) {
	$file = isset( $row['plugin'] ) ? (string) $row['plugin'] : '';
	if ( '' === $file ) {
		return '';
	}
	if ( '.php' !== substr( $file, -4 ) ) {
		$file .= '.php';
	}
	return $file;
}

/**
 * Lazily prime the `update_plugins` site transient so REST callers see
 * the same "updates available" picture as the classic Plugins screen.
 *
 * Core only refreshes the transient on `load-plugins.php`,
 * `load-update-core.php`, and the twice-daily cron — REST is not on
 * that list, so a fresh page load of the desktop Plugins window can
 * see an empty/stale transient even when the dock badge (computed
 * off `$menu`, which Core builds against `wp_get_update_data()`)
 * reports pending updates. We mirror Core's own throttle
 * (`wp-admin/includes/update.php::_maybe_update_plugins()` — 12h since
 * last check) so a hot REST hit is a transient read, not an HTTPS
 * round-trip to api.wordpress.org.
 *
 * Idempotent on its own (Core's 12h throttle); callers that hit this
 * many times per request should additionally guard with their own
 * static so they don't pay the transient-read overhead per row.
 *
 * @param bool $force When true, delete the transient and force a fresh
 *                    wp.org check regardless of the 12h throttle.
 */
function openstation_plugins_window_maybe_refresh_update_transient( $force = false ) {
	/**
	 * Short-circuit the lazy refresh of the `update_plugins` transient.
	 *
	 * Return `false` to skip the refresh — useful for hosts that run
	 * their own update orchestration (managed WordPress, internal
	 * mirrors) and don't want every REST hit to the plugins endpoint
	 * to potentially trigger a wp.org check. The filter also gates the
	 * explicit force-refresh path so hosts that block wp.org calls
	 * outright keep that posture even when the user clicks Refresh.
	 *
	 * @param bool $refresh Whether to call `wp_update_plugins()`.
	 * @param bool $force   Whether the caller asked to bypass the throttle.
	 */
	if ( ! apply_filters( 'openstation_plugins_window_refresh_updates', true, $force ) ) {
		return;
	}

	if ( ! function_exists( 'wp_update_plugins' ) ) {
		// `wp-includes/update.php` is normally autoloaded on every
		// request; guard anyway so an unusual bootstrap (mu-plugin
		// CLI harness, stripped-down REST runtime) doesn't fatal.
		return;
	}

	if ( $force ) {
		// Explicit user-initiated refresh — bypass the throttle.
		// Two steps:
		// 1. Delete the `update_plugins` site transient (and the
		// `plugins` cache group) via `wp_clean_plugins_cache()`,
		// OR fall back to `delete_site_transient()` directly when
		// the admin-side helper isn't loaded.
		// 2. Call `wp_update_plugins()` to repopulate the transient
		// with a fresh wp.org snapshot. Without step 2 the field
		// callback reads `false` for the rest of this request and
		// every row reports "no updates" — that's the exact
		// regression from the first cut of this fix (GH#202).
		if ( function_exists( 'wp_clean_plugins_cache' ) ) {
			wp_clean_plugins_cache( true );
		} else {
			delete_site_transient( 'update_plugins' );
		}
		wp_update_plugins();
		return;
	}

	$current = get_site_transient( 'update_plugins' );
	if (
		is_object( $current ) &&
		isset( $current->last_checked ) &&
		12 * HOUR_IN_SECONDS > ( time() - (int) $current->last_checked )
	) {
		// Inside Core's standard refresh window — trust the cached
		// snapshot, identical to `_maybe_update_plugins()`'s posture.
		return;
	}

	wp_update_plugins();
}

/**
 * Detect whether the current REST request asked for an explicit
 * `update_plugins` refresh via `?openstation_force_refresh=1`.
 *
 * The flag is set by the in-window Refresh button (see
 * `fetchInstalledPlugins({ force: true })` in `src/plugins-window/rest.ts`)
 * and read from the query string on the way through Core's REST
 * dispatcher. Querystring is the canonical channel — the value is an
 * idempotent "use the slow path" hint, not a state-changing action,
 * so no additional nonce is required beyond REST's standard
 * `X-WP-Nonce` cookie-auth check.
 *
 * @return bool True when the request asked for a force-refresh.
 */
function openstation_plugins_window_force_refresh_requested() {
	// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately.
	if ( ! isset( $_GET['openstation_force_refresh'] ) ) {
		return false;
	}
	// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately.
	$value = sanitize_text_field( wp_unslash( (string) $_GET['openstation_force_refresh'] ) );
	return '1' === $value || 'true' === $value;
}

/**
 * Prime the `update_plugins` transient at most once per request.
 */
function openstation_plugins_window_prime_updates_once() {
	static $primed = false;
	if ( $primed ) {
		return;
	}
	$primed = true;
	openstation_plugins_window_maybe_refresh_update_transient(
		openstation_plugins_window_force_refresh_requested()
	);
}

/**
 * `openstation_update_available` callback.
 *
 * @param array $row Core REST plugin row.
 * @return array{available:bool,new_version:string|null,package:string,slug:string}
 */
function openstation_plugins_window_field_update_available( $row ) {
	$plugin_file = openstation_plugins_window_row_plugin_file( $row );
	if ( '' === $plugin_file ) {
		return array(
			'available'   => false,
			'new_version' => null,
			'package'     => '',
			'slug'        => '',
		);
	}

	// Prime the transient once per request before reading it —
	// otherwise REST callers see a stale/empty snapshot relative to
	// the classic Plugins screen and the dock update badge. When the
	// request carries `?openstation_force_refresh=1` the helper always
	// takes the slow path so the in-window Refresh button can actually
	// pull a fresh wp.org snapshot (the original throttle made it a
	// no-op within 12h of the last check — see GH#202).
	openstation_plugins_window_prime_updates_once();

	// `update_plugins` is the canonical site-wide cache of pending
	// updates, refreshed by `wp_update_plugins()` on the standard
	// schedule. Reading it costs nothing.
	$updates = get_site_transient( 'update_plugins' );
	if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) {
		return array(
			'available'   => false,
			'new_version' => null,
			'package'     => '',
			'slug'        => '',
		);
	}

	if ( ! isset( $updates->response[ $plugin_file ] ) ) {
		return array(
			'available'   => false,
			'new_version' => null,
			'package'     => '',
			'slug'        => '',
		);
	}

	$entry = $updates->response[ $plugin_file ];
	$ver   = is_object( $entry ) && isset( $entry->new_version )
		? (string) $entry->new_version
		: null;
	// `package` is the download URL Core's upgrader hits to fetch the
	// new .zip. Empty for plugins that don't ship a wp.org package
	// (premium / private hosts) — Core renders an "Automatic update is
	// unavailable for this plugin" notice in that case rather than the
	// "Update now" link. We surface the URL so JS can apply the same
	// gating without needing a second round-trip.
	$package = is_object( $entry ) && ! empty( $entry->package )
		? (string) $entry->package
		: '';
	// `slug` is what Core's `wp_ajax_update_plugin` echoes back in its
	// success / error envelope. We forward what the transient already
	// carries; the AJAX handler doesn't require it on the request
	// side (it derives slug from `plugin`), but having it client-side
	// keeps event payloads symmetric with Core's own.
	$slug = is_object( $entry ) && ! empty( $entry->slug )
		? (string) $entry->slug
		: '';

	return array(
		'available'   => true,
		'new_version' => $ver,
		'package'     => $package,
		'slug'        => $slug,
	);
}

/**
 * Count plugin updates visible to the Plugins window — i.e. updates in
 * the `update_plugins` site transient whose key corresponds to an
 * actually-installed plugin file (`get_plugins()`).
 *
 * Core's `wp_get_update_data()` reports `count( $update_plugins->response )`
 * verbatim, which is what `wp-admin/menu.php` embeds in the Plugins
 * menu title (the source the dock-builder regex captures). That raw
 * count can drift above the in-window "Update available" filter when
 * the transient holds orphan entries — rows for plugin files that no
 * longer exist on disk, or rows injected via the standard `Update URI`
 * mechanism that key on a file `get_plugins()` doesn't return.
 *
 * The Plugins window iterates `get_plugins()` via REST and shows each
 * row as updatable iff `update_plugins->response[ $plugin_file ]` is
 * set — exactly the intersection we compute here. Using this count for
 * the dock badge guarantees the two surfaces agree (GH#258).
 *
 * @return int Number of installed plugins with a pending update.
 */
function openstation_plugins_window_count_visible_updates() {
	$updates = get_site_transient( 'update_plugins' );
	if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) {
		return 0;
	}

	// `get_plugins()` lives in `wp-admin/includes/plugin.php`. Loaded by
	// default on every admin request (which is where `$menu` is built),
	// but require it explicitly so REST + cron + WP-CLI callers can use
	// this helper without depending on the admin runtime.
	if ( ! function_exists( 'get_plugins' ) ) {
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
	}
	$installed = get_plugins();

	$count = 0;
	foreach ( array_keys( $updates->response ) as $plugin_file ) {
		if ( isset( $installed[ $plugin_file ] ) ) {
			++$count;
		}
	}
	return $count;
}

/**
 * `openstation_can_manage` callback.
 *
 * Per-row cap surface so the JS UI can hide actions the viewer can't
 * perform without re-deriving caps client-side. Server still
 * re-validates every mutation.
 *
 * @param array $row Core REST plugin row.
 * @return array{activate:bool,deactivate:bool,delete:bool}
 */
function openstation_plugins_window_field_can_manage( $row ) {
	$status = isset( $row['status'] ) ? (string) $row['status'] : '';

	$can_activate = current_user_can( 'activate_plugins' );
	$can_delete   = current_user_can( 'delete_plugins' );

	// Active plugins can only be deleted after deactivation; surface
	// that constraint so the JS can dim the Delete action while the
	// row is active.
	$can_delete_now = $can_delete && 'inactive' === $status;

	return array(
		'activate'   => $can_activate && 'inactive' === $status,
		'deactivate' => $can_activate && 'active' === $status,
		'delete'     => $can_delete_now,
	);
}

/**
 * What wp.org last said about one plugin — `slug`, `icons`, versions.
 *
 * Both halves have to be read: a plugin is filed under `response`
 * when an update is pending and `no_update` otherwise, with the same
 * directory metadata in each. Reading only `response` misses every
 * up-to-date plugin.
 *
 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
 * @return array|null Null when wp.org doesn't know this plugin, or the
 *                    transient is cold.
 */
function openstation_plugins_window_update_entry( $plugin_file ) {
	if ( '' === $plugin_file ) {
		return null;
	}

	openstation_plugins_window_prime_updates_once();

	$updates = get_site_transient( 'update_plugins' );
	if ( ! is_object( $updates ) ) {
		return null;
	}
	if ( isset( $updates->response[ $plugin_file ] ) ) {
		return (array) $updates->response[ $plugin_file ];
	}
	if ( isset( $updates->no_update[ $plugin_file ] ) ) {
		return (array) $updates->no_update[ $plugin_file ];
	}

	return null;
}

/**
 * `openstation_wporg_slug` callback.
 *
 * Is this plugin listed on the WordPress.org directory, and under
 * which slug?
 *
 * This mirrors Core (see `WP_Plugins_List_Table::prepare_items()`).
 *
 * @param array $row Core REST plugin row.
 * @return string|null Directory slug, or null when the plugin isn't listed.
 */
function openstation_plugins_window_field_wporg_slug( $row ) {
	$entry = openstation_plugins_window_update_entry(
		openstation_plugins_window_row_plugin_file( $row )
	);

	if ( null === $entry || empty( $entry['slug'] ) ) {
		return null;
	}

	$slug = sanitize_key( (string) $entry['slug'] );

	return '' !== $slug ? $slug : null;
}

/**
 * `openstation_icon_url` callback.
 *
 * Resolves a card icon URL for an installed plugin row, in priority:
 *
 *   1. **Local file** — if the plugin's own folder ships an icon at a
 *      conventional path (`assets/icon.svg`, `assets/icon-256x256.png`,
 *      `assets/icon-128x128.png`, or the same names at the folder
 *      root), return its `plugins_url()`. This is what makes premium /
 *      internal / native-bundled plugins (alcazaba-*, os-*,
 *      and any private plugin that ships its own art) display
 *      correctly — they aren't on `ps.w.org/<slug>/`, so the wp.org
 *      candidate chain 404s through every variant before the
 *      placeholder paints.
 *   2. **The `icons` map wp.org returned** for this plugin, cached in
 *      the `update_plugins` transient — a URL the directory gave us
 *      rather than one we built.
 *   3. **Guessed SVN asset** — `https://ps.w.org/<slug>/assets/icon.svg`,
 *      for when that metadata isn't cached. `<slug>` prefers the
 *      directory slug, then the folder name, then the textdomain.
 *      Last, because both halves of the guess are unknowable: the
 *      format (Gutenberg and UpdraftPlus ship JPEG only) and the slug
 *      (`hello.php` is listed as `hello-dolly`).
 *
 * Skipped entirely when step 2 established the plugin uploaded no art:
 * `null` paints the placeholder without a request, where guessing would
 * spend a 404 per candidate arriving at the same picture.
 *
 * @param array $row Core REST plugin row.
 * @return string|null
 */
function openstation_plugins_window_field_icon_url( $row ) {
	$plugin_file = openstation_plugins_window_row_plugin_file( $row );
	$entry       = openstation_plugins_window_update_entry( $plugin_file );
	$folder      = '' !== $plugin_file ? dirname( $plugin_file ) : '';
	$slug        = ( '' !== $folder && '.' !== $folder ) ? $folder : '';

	// wp.org's own slug when it knows the plugin; the rest are inferred.
	if ( null !== $entry && ! empty( $entry['slug'] ) ) {
		$slug = (string) $entry['slug'];
	} elseif ( '' === $slug ) {
		// Single-file plugin (e.g. hello.php at the plugins root) —
		// no folder slug, so fall back to the text domain.
		$slug = isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '';
	}

	$slug = sanitize_key( $slug );
	if ( '' === $slug ) {
		return null;
	}

	$default = openstation_plugins_window_local_icon_url( $plugin_file );
	$no_art  = false;
	if ( null === $default && null !== $entry ) {
		$default = openstation_plugins_window_directory_icon_url( $entry );
		$no_art = ( null === $default && ! empty( $entry['icons'] ) );
	}
	if ( null === $default && ! $no_art ) {
		/*
		 * Plugin Check's offloading rule is right in general and does
		 * not fit here, so the suppression is one line wide and says
		 * why, rather than living in a project-wide ignore list where
		 * it would also cover the next offload someone adds.
		 *
		 * `ps.w.org` is WordPress.org's own plugin-asset host — the
		 * same origin core's "Add Plugins" screen paints its cards
		 * from. This is directory artwork for plugins we do not ship
		 * and cannot bundle: there is nothing local to offload FROM,
		 * and the alternative is not "host it ourselves" but "no
		 * icon".
		 *
		 * It is already the last resort. The local-icon lookup above
		 * wins whenever a plugin ships art at a conventional path,
		 * nothing here is enqueued (it becomes an `<img src>`, not a
		 * script or a stylesheet), and the card walks a candidate
		 * chain before falling back to a dashicon placeholder — so a
		 * blocked or offline host costs the user the picture and
		 * nothing else.
		 */
		// phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- wp.org's own asset host, for directory art this plugin cannot bundle; degrades to a placeholder.
		$default = 'https://ps.w.org/' . $slug . '/assets/icon.svg';
	}

	/**
	 * Filter the resolved icon URL for a plugin row.
	 *
	 * Return `null` to suppress the icon (forces the placeholder).
	 * Return a different URL to override the default — useful for
	 * custom CDN art or for overriding the auto-detected local icon.
	 *
	 * The `$url` parameter is a local `plugins_url()`, a URL from
	 * wp.org's `icons` map, or the guessed
	 * `ps.w.org/<slug>/assets/icon.svg`. Only that last shape walks the
	 * JS candidate chain on `<img>` error; every other URL is one-shot,
	 * then placeholder.
	 *
	 * @param string|null $url  Default URL — see the ladder above.
	 * @param string      $slug Directory slug when wp.org knows the
	 *                          plugin, else folder name or textdomain.
	 * @param array       $row  Core REST plugin row.
	 */
	return apply_filters(
		'openstation_plugins_window_icon_url',
		$default,
		$slug,
		$row
	);
}

/**
 * Pick a card icon out of the `icons` map wp.org returned.
 *
 * `svg` → `2x` → `1x`, the ladder core's Add Plugins cards use (see
 * `WP_Plugin_Install_List_Table::display_rows()`); matching it is what
 * makes the two screens agree. `default` — wp.org's geopattern for
 * plugins that uploaded no art — is skipped, so those keep the
 * window's own placeholder.
 *
 * @param array $entry An `update_plugins` entry.
 * @return string|null Null when the entry carries no art.
 */
function openstation_plugins_window_directory_icon_url( $entry ) {
	if ( empty( $entry['icons'] ) || ! is_array( $entry['icons'] ) ) {
		return null;
	}

	$icons = $entry['icons'];
	foreach ( array( 'svg', '2x', '1x' ) as $size ) {
		if ( empty( $icons[ $size ] ) || ! is_string( $icons[ $size ] ) ) {
			continue;
		}
		$url = esc_url_raw( $icons[ $size ] );
		if ( '' !== $url ) {
			return $url;
		}
	}

	return null;
}

/**
 * Probe an installed plugin's own folder for a card icon.
 *
 * Many premium and private plugins (and our own native extensions —
 * alcazaba-*, os-*) aren't on the .org repo, so the wp.org
 * SVN URL 404s through every candidate before the placeholder paints.
 * Most that ship art do so at a conventional location inside their
 * own folder — typically `assets/icon.svg` mirroring the wp.org SVN
 * /assets/ layout, occasionally bare `icon.svg` at the root for
 * minimal plugins. We probe both shapes and return the first URL we
 * resolve, or `null` when nothing matches.
 *
 * Single-file plugins (no folder) return `null` immediately — there's
 * no folder to scan.
 *
 * Cost: 1–6 `file_exists()` calls per row, ~1µs each with warm OS
 * cache. For a 50-row paint this is well under a millisecond — not
 * worth caching, and a cache would have to invalidate on plugin
 * install/update/delete.
 *
 * The candidate list is filterable via
 * `openstation_plugins_window_local_icon_candidates` so a host can
 * support a custom convention (e.g. an `icon@2x.svg` shape).
 *
 * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`).
 * @return string|null URL of the first local icon found, or null.
 */
function openstation_plugins_window_local_icon_url( $plugin_file ) {
	if ( '' === $plugin_file ) {
		return null;
	}
	$folder = dirname( $plugin_file );
	if ( '' === $folder || '.' === $folder ) {
		// Single-file plugin — no folder to scan.
		return null;
	}

	/**
	 * Filter the ordered list of relative paths probed inside an
	 * installed plugin's folder when looking for a card icon. The
	 * first existing file wins; later entries are ignored.
	 *
	 * @param string[] $candidates Relative paths under the plugin folder.
	 * @param string   $folder     Plugin folder name (e.g. `"akismet"`).
	 */
	$candidates = apply_filters(
		'openstation_plugins_window_local_icon_candidates',
		array(
			'assets/icon.svg',
			'assets/icon-256x256.png',
			'assets/icon-256x256.jpg',
			'assets/icon-256x256.jpeg',
			'assets/icon-128x128.png',
			'assets/icon-128x128.jpg',
			'assets/icon-128x128.jpeg',
			'icon.svg',
			'icon-256x256.png',
			'icon-256x256.jpg',
			'icon-256x256.jpeg',
			'icon-128x128.png',
			'icon-128x128.jpg',
			'icon-128x128.jpeg',
		),
		$folder
	);

	$plugin_root = WP_PLUGIN_DIR . '/' . $folder;
	foreach ( (array) $candidates as $relative ) {
		$relative = (string) $relative;
		if ( '' === $relative ) {
			continue;
		}
		if ( file_exists( $plugin_root . '/' . $relative ) ) {
			return plugins_url( $relative, WP_PLUGIN_DIR . '/' . $plugin_file );
		}
	}

	return null;
}

/**
 * `openstation_size_kb` callback. Caches per-plugin for 6 hours so
 * a 50-row table doesn't `glob`+`filesize` 50 directories on every
 * fetch. Returns `null` when the folder can't be read.
 *
 * @param array $row Core REST plugin row.
 * @return int|null Size in kilobytes, or null on failure.
 */
function openstation_plugins_window_field_size_kb( $row ) {
	$plugin_file = openstation_plugins_window_row_plugin_file( $row );
	if ( '' === $plugin_file ) {
		return null;
	}

	// `WP_PLUGIN_DIR` is defined in `wp-includes/default-constants.php`
	// — safe to reference anywhere.
	$plugin_dir = WP_PLUGIN_DIR;
	$root       = $plugin_dir . '/' . dirname( $plugin_file );
	if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) {
		// Single-file plugins (e.g. hello.php at the root of plugins/).
		$candidate = $plugin_dir . '/' . $plugin_file;
		if ( is_file( $candidate ) ) {
			$bytes = (int) filesize( $candidate );
			return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0;
		}
		return null;
	}

	$cache_key = 'dm_pwsz_' . md5( $plugin_file );
	$cached    = get_transient( $cache_key );
	if ( false !== $cached && is_int( $cached ) ) {
		return $cached;
	}

	$kb = openstation_plugins_window_compute_dir_size_kb( $root );
	set_transient( $cache_key, $kb, 6 * HOUR_IN_SECONDS );
	return $kb;
}

/**
 * Recursively sum file sizes under `$dir`, returning kilobytes.
 *
 * Caps total iteration to 5,000 entries so a pathological symlink
 * loop (or an enormous plugin folder full of vendor cruft) can't
 * stall a REST response. When the cap trips we return whatever we
 * counted so far — a slight under-report is better than a hung
 * request.
 *
 * @param string $dir Absolute filesystem path.
 * @return int Kilobytes (rounded).
 */
function openstation_plugins_window_compute_dir_size_kb( $dir ) {
	if ( ! is_dir( $dir ) ) {
		return 0;
	}

	$total_bytes = 0;
	$visited     = 0;
	$max_visit   = 5000;

	$stack = array( $dir );
	while ( ! empty( $stack ) && $visited < $max_visit ) {
		$current = array_pop( $stack );
		$entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null.
		if ( ! is_array( $entries ) ) {
			continue;
		}
		foreach ( $entries as $entry ) {
			if ( '.' === $entry || '..' === $entry ) {
				continue;
			}
			$path = $current . '/' . $entry;
			if ( is_link( $path ) ) {
				// Skip symlinks: they could escape the plugin folder
				// or recurse infinitely. The classic admin's plugin
				// list ignores symlink contents for the same reason.
				continue;
			}
			++$visited;
			if ( $visited >= $max_visit ) {
				break 2;
			}
			if ( is_dir( $path ) ) {
				$stack[] = $path;
			} elseif ( is_file( $path ) ) {
				$total_bytes += (int) filesize( $path );
			}
		}
	}

	return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0;
}

/**
 * `openstation_auto_update` callback.
 *
 * Mirrors the per-row state Core derives in
 * `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates"
 * column. Shape:
 *
 *   - `enabled`   bool  — the plugin file is currently in the
 *                          `auto_update_plugins` site option, OR a
 *                          filter has forced auto-updates on.
 *   - `forced`    bool|null — `true`/`false` when the
 *                          `auto_update_plugin` filter pinned the state,
 *                          `null` when the user is free to toggle.
 *   - `supported` bool  — whether the `update_plugins` transient has an
 *                          entry for this plugin (either in `response` or
 *                          `no_update`). Core hides the toggle entirely
 *                          when this is false — premium / private plugins
 *                          that never check in with wp.org.
 *
 * NOT included here (lives on the window config instead): the global
 * `wp_is_auto_update_enabled_for_type( 'plugin' )` flag, which depends
 * on admin-only includes — see `openstation_plugins_window_auto_updates_enabled()`.
 *
 * @param array $row Core REST plugin row.
 * @return array{enabled:bool,forced:bool|null,supported:bool}
 */
function openstation_plugins_window_field_auto_update( $row ) {
	$plugin_file = openstation_plugins_window_row_plugin_file( $row );
	if ( '' === $plugin_file ) {
		return array(
			'enabled'   => false,
			'forced'    => null,
			'supported' => false,
		);
	}

	$auto_updates = (array) get_site_option( 'auto_update_plugins', array() );
	$enabled      = in_array( $plugin_file, $auto_updates, true );

	// `update-supported` mirrors Core's logic: a plugin is "supported"
	// for auto-update toggling when wp.org has either a pending update
	// row OR an explicit no-update row in the `update_plugins` transient.
	// Premium / private plugins that never call home land in neither
	// bucket — Core hides the toggle so the user doesn't enable an
	// auto-update that can't ever fire.
	$supported = false;
	$updates   = get_site_transient( 'update_plugins' );
	if ( is_object( $updates ) ) {
		if ( isset( $updates->response[ $plugin_file ] ) || isset( $updates->no_update[ $plugin_file ] ) ) {
			$supported = true;
		}
	}

	// Build the payload Core's filter expects (mirrors
	// `WP_Plugins_List_Table::prepare_items()`'s `$filter_payload`).
	// `wp_is_auto_update_forced_for_item()` itself is in
	// `wp-admin/includes/update.php` — we can't include that from a REST
	// callback (Plugin Check), so we run the filter directly. It's a
	// single `apply_filters()` call under the hood.
	//
	// Important: `wp_parse_args( $row, $defaults )` lets `$row` keys
	// override `$defaults`. Core's REST controller strips `.php` from
	// the `plugin` field, but every filter that hooks `auto_update_plugin`
	// (including Core's own) reads `$item->plugin` expecting the FULL
	// filename. We layer the normalized `$plugin_file` AFTER the parse
	// so it always wins.
	$filter_payload           = wp_parse_args(
		$row,
		array(
			'id'            => $plugin_file,
			'slug'          => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '',
			'plugin'        => $plugin_file,
			'new_version'   => '',
			'url'           => '',
			'package'       => '',
			'icons'         => array(),
			'banners'       => array(),
			'banners_rtl'   => array(),
			'tested'        => '',
			'requires_php'  => '',
			'compatibility' => new stdClass(),
		)
	);
	$filter_payload['plugin'] = $plugin_file;
	$filter_payload['id']     = $plugin_file;
	$filter_payload           = (object) $filter_payload;
	/** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */
	$forced = apply_filters( 'auto_update_plugin', null, $filter_payload ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter; the effective auto-update state has to come from the same source Core reads.
	if ( null !== $forced ) {
		$forced = (bool) $forced;
		// When a filter forces the state, that's the effective state
		// regardless of the `auto_update_plugins` option — match Core's
		// rendering in `single_row_columns()`.
		$enabled = $forced;
	}

	return array(
		'enabled'   => (bool) $enabled,
		'forced'    => $forced,
		'supported' => $supported,
	);
}

```
