'desktop_mode_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', 'desktop_mode_can_manage', array( 'get_callback' => 'desktop_mode_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', 'desktop_mode_icon_url', array( 'get_callback' => 'desktop_mode_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', 'desktop_mode_size_kb', array( 'get_callback' => 'desktop_mode_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', 'desktop_mode_auto_update', array( 'get_callback' => 'desktop_mode_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', 'desktop_mode_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. * * @since 0.18.0 * * @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 desktop_mode_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. * * @since 0.18.0 * @since 0.8.5 Accepts a `$force` flag — set by the in-window Refresh * button via `?desktop_mode_force_refresh=1`. Bypasses * the 12h throttle and runs `wp_clean_plugins_cache( true )` * so the next read sees a fresh wp.org snapshot. Without * this escape hatch the Refresh button was misleading: * within 12h of the last check it returned the same * cached "no updates" result Core had stored, while * classic admin's `plugins.php` (which always calls * `wp_clean_plugins_cache( true )`) showed pending updates. * * @param bool $force When true, delete the transient and force a fresh * wp.org check regardless of the 12h throttle. */ function desktop_mode_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. * * @since 0.18.0 * @since 0.8.5 `$force` parameter added so filter callbacks can * distinguish opportunistic refreshes from explicit * user-initiated ones. * * @param bool $refresh Whether to call `wp_update_plugins()`. * @param bool $force Whether the caller asked to bypass the throttle. */ if ( ! apply_filters( 'desktop_mode_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 `?desktop_mode_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. * * @since 0.8.5 * * @return bool True when the request asked for a force-refresh. */ function desktop_mode_plugins_window_force_refresh_requested() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately. if ( ! isset( $_GET['desktop_mode_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['desktop_mode_force_refresh'] ) ); return '1' === $value || 'true' === $value; } /** * `desktop_mode_update_available` callback. * * @since 0.9.0 * * @param array $row Core REST plugin row. * @return array{available:bool,new_version:string|null,package:string,slug:string} */ function desktop_mode_plugins_window_field_update_available( $row ) { $plugin_file = desktop_mode_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. Static // guard keeps the transient read off the hot per-row path. When // the request carries `?desktop_mode_force_refresh=1` we always // take 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). static $primed = false; if ( ! $primed ) { $primed = true; desktop_mode_plugins_window_maybe_refresh_update_transient( desktop_mode_plugins_window_force_refresh_requested() ); } // `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). * * @since 0.8.8 * * @return int Number of installed plugins with a pending update. */ function desktop_mode_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; } /** * `desktop_mode_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. * * @since 0.9.0 * * @param array $row Core REST plugin row. * @return array{activate:bool,deactivate:bool,delete:bool} */ function desktop_mode_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, ); } /** * `desktop_mode_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-*, desktop-mode-*, * and any private plugin that ships its own art) display * correctly — they aren't on `ps.w.org//`, so the wp.org * candidate chain 404s through every variant before the * placeholder paints. * 2. **wp.org SVN asset** — `https://ps.w.org//assets/icon.svg`, * keyed off the plugin's **folder name** (which is the .org repo * slug). Folder beats textdomain because the two often diverge * (`woocommerce` vs textdomain `woo`, `wordpress-seo` vs * `yoast-seo`). Falls back to textdomain for single-file plugins. * * We don't HEAD-check the URL — the JS card walks a candidate chain * (SVG → 256 PNG → 256 GIF → 128 PNG → 128 GIF) on `` error for wp.org URLs, then * drops to a `` placeholder. * A 404 here costs nothing. * * @since 0.9.0 * @since 0.8.6 Probes the plugin's own folder for an icon before * falling back to the wp.org SVN URL. * * @param array $row Core REST plugin row. * @return string|null */ function desktop_mode_plugins_window_field_icon_url( $row ) { $plugin_file = desktop_mode_plugins_window_row_plugin_file( $row ); $folder = '' !== $plugin_file ? dirname( $plugin_file ) : ''; $slug = ( '' !== $folder && '.' !== $folder ) ? $folder : ''; if ( '' === $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 = desktop_mode_plugins_window_local_icon_url( $plugin_file ); if ( null === $default ) { $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 either a local `plugins_url()` (when the * plugin's own folder ships an icon at a conventional path) or the * wp.org `ps.w.org//assets/icon.svg` URL. The JS receiver * walks a candidate chain on `` error (`icon.svg` → 256 PNG → * 128 PNG) only when the URL matches the wp.org SVN pattern; * custom URLs and local URLs are one-shot, then placeholder. * * @since 0.9.0 * * @param string|null $url Default URL (local file if the plugin's * folder ships one, else wp.org SVG). * @param string $slug Plugin slug (folder name, or textdomain * for single-file plugins). * @param array $row Core REST plugin row. */ return apply_filters( 'desktop_mode_plugins_window_icon_url', $default, $slug, $row ); } /** * Probe an installed plugin's own folder for a card icon. * * Many premium and private plugins (and our own native extensions — * alcazaba-*, desktop-mode-*) 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 * `desktop_mode_plugins_window_local_icon_candidates` so a host can * support a custom convention (e.g. an `icon@2x.svg` shape). * * @since 0.8.6 * * @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`). * @return string|null URL of the first local icon found, or null. */ function desktop_mode_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. * * @since 0.8.6 * * @param string[] $candidates Relative paths under the plugin folder. * @param string $folder Plugin folder name (e.g. `"akismet"`). */ $candidates = apply_filters( 'desktop_mode_plugins_window_local_icon_candidates', array( 'assets/icon.svg', 'assets/icon-256x256.png', 'assets/icon-128x128.png', 'icon.svg', 'icon-256x256.png', 'icon-128x128.png', ), $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; } /** * `desktop_mode_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. * * @since 0.9.0 * * @param array $row Core REST plugin row. * @return int|null Size in kilobytes, or null on failure. */ function desktop_mode_plugins_window_field_size_kb( $row ) { $plugin_file = desktop_mode_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 = desktop_mode_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. * * @since 0.9.0 * * @param string $dir Absolute filesystem path. * @return int Kilobytes (rounded). */ function desktop_mode_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; } /** * `desktop_mode_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 `desktop_mode_plugins_window_auto_updates_enabled()`. * * @since 0.21.0 * * @param array $row Core REST plugin row. * @return array{enabled:bool,forced:bool|null,supported:bool} */ function desktop_mode_plugins_window_field_auto_update( $row ) { $plugin_file = desktop_mode_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 ); 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, ); }