| 1 |
<?php |
| 2 |
/** |
| 3 |
* Plugins app — what the `update_plugins` transient says. |
| 4 |
* |
| 5 |
* Part of the `desktop-mode-plugins` app: required by `plugins.os.php`, |
| 6 |
* plain `.php` on purpose — only `*.os.php` files are app entries to |
| 7 |
* the framework loader. The transient is primed at most once per |
| 8 |
* request (Core's own 12h throttle, or the Refresh button's forced |
| 9 |
* check) and read into one per-request snapshot; the three REST |
| 10 |
* fields derived from it — the pending update, the directory slug, |
| 11 |
* the auto-update state — read that snapshot, as does the dock badge |
| 12 |
* count. Every callback uses only `wp-includes/` functions. |
| 13 |
* |
| 14 |
* @package OpenStation |
| 15 |
*/ |
| 16 |
|
| 17 |
// Direct access, unless a standalone host is booting on bare PHP. |
| 18 |
if ( ! defined( 'ABSPATH' ) ) { |
| 19 |
defined( 'OPENSTATION_STANDALONE' ) || exit; |
| 20 |
} |
| 21 |
|
| 22 |
/** |
| 23 |
* Lazily prime the `update_plugins` site transient so REST callers see |
| 24 |
* the same "updates available" picture as the classic Plugins screen. |
| 25 |
* |
| 26 |
* Core only refreshes the transient on `load-plugins.php`, |
| 27 |
* `load-update-core.php`, and the twice-daily cron — REST is not on |
| 28 |
* that list, so a fresh page load of the Plugins window can see an |
| 29 |
* empty or stale transient even when the dock badge (computed off |
| 30 |
* `$menu`, which Core builds against `wp_get_update_data()`) reports |
| 31 |
* pending updates. We mirror Core's own throttle |
| 32 |
* (`_maybe_update_plugins()` — 12h since last check) so a hot REST hit |
| 33 |
* is a transient read, not an HTTPS round trip to api.wordpress.org. |
| 34 |
* |
| 35 |
* @param bool $force When true, delete the transient and force a fresh |
| 36 |
* wp.org check regardless of the 12h throttle. |
| 37 |
*/ |
| 38 |
function openstation_plugins_window_maybe_refresh_update_transient( $force = false ) { |
| 39 |
/** |
| 40 |
* Short-circuit the lazy refresh of the `update_plugins` transient. |
| 41 |
* |
| 42 |
* Return `false` to skip the refresh — useful for hosts that run |
| 43 |
* their own update orchestration (managed WordPress, internal |
| 44 |
* mirrors) and don't want every REST hit to the plugins endpoint |
| 45 |
* to potentially trigger a wp.org check. The filter also gates the |
| 46 |
* Refresh button's forced check so hosts that block wp.org calls |
| 47 |
* outright keep that posture even when the user asks. |
| 48 |
* |
| 49 |
* @param bool $refresh Whether to call `wp_update_plugins()`. |
| 50 |
* @param bool $force Whether the caller asked to bypass the throttle. |
| 51 |
*/ |
| 52 |
if ( ! apply_filters( 'openstation_plugins_window_refresh_updates', true, $force ) ) { |
| 53 |
return; |
| 54 |
} |
| 55 |
|
| 56 |
if ( ! function_exists( 'wp_update_plugins' ) ) { |
| 57 |
// `wp-includes/update.php` is normally autoloaded on every |
| 58 |
// request; guard anyway so an unusual bootstrap (mu-plugin |
| 59 |
// CLI harness, stripped-down REST runtime) doesn't fatal. |
| 60 |
return; |
| 61 |
} |
| 62 |
|
| 63 |
if ( $force ) { |
| 64 |
// A user-initiated refresh bypasses the throttle: delete the |
| 65 |
// transient (and the `plugins` cache group), then repopulate it |
| 66 |
// with a fresh wp.org snapshot. Without the second step the |
| 67 |
// field callbacks read `false` for the rest of the request and |
| 68 |
// every row reports "no updates". |
| 69 |
if ( function_exists( 'wp_clean_plugins_cache' ) ) { |
| 70 |
wp_clean_plugins_cache( true ); |
| 71 |
} else { |
| 72 |
delete_site_transient( 'update_plugins' ); |
| 73 |
} |
| 74 |
wp_update_plugins(); |
| 75 |
return; |
| 76 |
} |
| 77 |
|
| 78 |
$current = get_site_transient( 'update_plugins' ); |
| 79 |
if ( |
| 80 |
is_object( $current ) && |
| 81 |
isset( $current->last_checked ) && |
| 82 |
12 * HOUR_IN_SECONDS > ( time() - (int) $current->last_checked ) |
| 83 |
) { |
| 84 |
// Inside Core's standard refresh window — trust the cached |
| 85 |
// snapshot, identical to `_maybe_update_plugins()`'s posture. |
| 86 |
return; |
| 87 |
} |
| 88 |
|
| 89 |
wp_update_plugins(); |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* Prime the `update_plugins` transient at most once per request — the |
| 94 |
* Refresh action's forced check counts, so the `data()` read that |
| 95 |
* follows it never asks wp.org twice. |
| 96 |
* |
| 97 |
* @param bool $force Bypass Core's throttle (the Refresh button). |
| 98 |
* @return void |
| 99 |
*/ |
| 100 |
function openstation_plugins_window_prime_updates_once( $force = false ) { |
| 101 |
static $primed = false; |
| 102 |
if ( $primed && ! $force ) { |
| 103 |
return; |
| 104 |
} |
| 105 |
$primed = true; |
| 106 |
openstation_plugins_window_maybe_refresh_update_transient( $force ); |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* The `update_plugins` transient, primed at most once per request: |
| 111 |
* every row's fields and the badge count read it through here. |
| 112 |
* |
| 113 |
* Deliberately NOT memoised on top of Core. `get_site_transient()` is |
| 114 |
* already an in-memory read after the first call of a request, and a |
| 115 |
* memo of our own would have to be invalidated whenever anything else |
| 116 |
* rewrites the transient — an upgrader finishing, a forced refresh, a |
| 117 |
* test priming a fixture. The obvious listeners for that are the |
| 118 |
* transient's own set/delete hooks, and Plugin Check reads any mention |
| 119 |
* of those two names as a self-hosted plugin updater |
| 120 |
* (`plugin_updater_detected`), which a wp.org-hosted plugin may not |
| 121 |
* ship. Reading Core's cache every time costs nothing and cannot go |
| 122 |
* stale. |
| 123 |
* |
| 124 |
* @return object|null The transient object, or null when it is cold. |
| 125 |
*/ |
| 126 |
function openstation_plugins_window_updates() { |
| 127 |
openstation_plugins_window_prime_updates_once(); |
| 128 |
$updates = get_site_transient( 'update_plugins' ); |
| 129 |
return is_object( $updates ) ? $updates : null; |
| 130 |
} |
| 131 |
|
| 132 |
|
| 133 |
/** |
| 134 |
* `openstation_update_available` callback. |
| 135 |
* |
| 136 |
* @param array $row Core REST plugin row. |
| 137 |
* @return array{available:bool,new_version:string|null,package:string,slug:string} |
| 138 |
*/ |
| 139 |
function openstation_plugins_window_field_update_available( $row ) { |
| 140 |
$none = array( |
| 141 |
'available' => false, |
| 142 |
'new_version' => null, |
| 143 |
'package' => '', |
| 144 |
'slug' => '', |
| 145 |
); |
| 146 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 147 |
if ( '' === $plugin_file ) { |
| 148 |
return $none; |
| 149 |
} |
| 150 |
|
| 151 |
$updates = openstation_plugins_window_updates(); |
| 152 |
if ( null === $updates || empty( $updates->response ) || ! is_array( $updates->response ) ) { |
| 153 |
return $none; |
| 154 |
} |
| 155 |
if ( ! isset( $updates->response[ $plugin_file ] ) ) { |
| 156 |
return $none; |
| 157 |
} |
| 158 |
|
| 159 |
$entry = $updates->response[ $plugin_file ]; |
| 160 |
return array( |
| 161 |
'available' => true, |
| 162 |
'new_version' => is_object( $entry ) && isset( $entry->new_version ) ? (string) $entry->new_version : null, |
| 163 |
// The download URL Core's upgrader fetches. Empty for plugins |
| 164 |
// without a wp.org package (premium / private hosts) — Core |
| 165 |
// renders "Automatic update is unavailable" there rather than |
| 166 |
// "Update now", and so does the client. |
| 167 |
'package' => is_object( $entry ) && ! empty( $entry->package ) ? (string) $entry->package : '', |
| 168 |
// What `wp_ajax_update_plugin` echoes back in its envelope. |
| 169 |
'slug' => is_object( $entry ) && ! empty( $entry->slug ) ? (string) $entry->slug : '', |
| 170 |
); |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Count plugin updates visible to the Plugins window — updates in the |
| 175 |
* `update_plugins` transient whose key is an installed plugin file. |
| 176 |
* |
| 177 |
* Core's `wp_get_update_data()` reports `count( $response )` verbatim, |
| 178 |
* which is what `wp-admin/menu.php` embeds in the Plugins menu title |
| 179 |
* (the source the dock builder captures). That raw count drifts above |
| 180 |
* the in-window "Update available" filter when the transient holds |
| 181 |
* orphan entries — files no longer on disk, or rows an `Update URI` |
| 182 |
* host keyed on a file `get_plugins()` doesn't return. The window |
| 183 |
* shows a row as updatable iff `response[ $plugin_file ]` is set — |
| 184 |
* exactly the intersection computed here, so the two surfaces agree. |
| 185 |
* |
| 186 |
* @return int Number of installed plugins with a pending update. |
| 187 |
*/ |
| 188 |
function openstation_plugins_window_count_visible_updates() { |
| 189 |
$updates = get_site_transient( 'update_plugins' ); |
| 190 |
if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) { |
| 191 |
return 0; |
| 192 |
} |
| 193 |
|
| 194 |
// `get_plugins()` lives in `wp-admin/includes/plugin.php`. Loaded by |
| 195 |
// default on every admin request (where `$menu` is built), but |
| 196 |
// required explicitly so REST, cron and WP-CLI callers can use this |
| 197 |
// helper without depending on the admin runtime. |
| 198 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 199 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 200 |
} |
| 201 |
$installed = get_plugins(); |
| 202 |
|
| 203 |
$count = 0; |
| 204 |
foreach ( array_keys( $updates->response ) as $plugin_file ) { |
| 205 |
if ( isset( $installed[ $plugin_file ] ) ) { |
| 206 |
++$count; |
| 207 |
} |
| 208 |
} |
| 209 |
return $count; |
| 210 |
} |
| 211 |
|
| 212 |
/** |
| 213 |
* What wp.org last said about one plugin — `slug`, `icons`, versions. |
| 214 |
* |
| 215 |
* Both halves have to be read: a plugin is filed under `response` |
| 216 |
* when an update is pending and `no_update` otherwise, with the same |
| 217 |
* directory metadata in each. Reading only `response` misses every |
| 218 |
* up-to-date plugin. |
| 219 |
* |
| 220 |
* @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`). |
| 221 |
* @return array|null Null when wp.org doesn't know this plugin, or the |
| 222 |
* transient is cold. |
| 223 |
*/ |
| 224 |
function openstation_plugins_window_update_entry( $plugin_file ) { |
| 225 |
if ( '' === $plugin_file ) { |
| 226 |
return null; |
| 227 |
} |
| 228 |
$updates = openstation_plugins_window_updates(); |
| 229 |
if ( null === $updates ) { |
| 230 |
return null; |
| 231 |
} |
| 232 |
if ( isset( $updates->response[ $plugin_file ] ) ) { |
| 233 |
return (array) $updates->response[ $plugin_file ]; |
| 234 |
} |
| 235 |
if ( isset( $updates->no_update[ $plugin_file ] ) ) { |
| 236 |
return (array) $updates->no_update[ $plugin_file ]; |
| 237 |
} |
| 238 |
return null; |
| 239 |
} |
| 240 |
|
| 241 |
/** |
| 242 |
* `openstation_wporg_slug` callback — is this plugin listed on the |
| 243 |
* WordPress.org directory, and under which slug? Mirrors Core (see |
| 244 |
* `WP_Plugins_List_Table::prepare_items()`). |
| 245 |
* |
| 246 |
* @param array $row Core REST plugin row. |
| 247 |
* @return string|null Directory slug, or null when the plugin isn't listed. |
| 248 |
*/ |
| 249 |
function openstation_plugins_window_field_wporg_slug( $row ) { |
| 250 |
$entry = openstation_plugins_window_update_entry( |
| 251 |
openstation_plugins_window_row_plugin_file( $row ) |
| 252 |
); |
| 253 |
if ( null === $entry || empty( $entry['slug'] ) ) { |
| 254 |
return null; |
| 255 |
} |
| 256 |
$slug = sanitize_key( (string) $entry['slug'] ); |
| 257 |
return '' !== $slug ? $slug : null; |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* `openstation_auto_update` callback. |
| 262 |
* |
| 263 |
* Mirrors the per-row state Core derives in |
| 264 |
* `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates" |
| 265 |
* column: |
| 266 |
* |
| 267 |
* - `enabled` bool — the file is in the `auto_update_plugins` |
| 268 |
* site option, or a filter forced it on. |
| 269 |
* - `forced` bool|null — `true`/`false` when the `auto_update_plugin` |
| 270 |
* filter pinned the state, `null` when the |
| 271 |
* user is free to toggle. |
| 272 |
* - `supported` bool — the `update_plugins` transient knows the |
| 273 |
* plugin (`response` or `no_update`). Core |
| 274 |
* hides the toggle otherwise — premium / |
| 275 |
* private plugins never check in. |
| 276 |
* |
| 277 |
* The global `wp_is_auto_update_enabled_for_type( 'plugin' )` gate is |
| 278 |
* on the window config instead — see |
| 279 |
* `openstation_plugins_window_auto_updates_enabled()`. |
| 280 |
* |
| 281 |
* @param array $row Core REST plugin row. |
| 282 |
* @return array{enabled:bool,forced:bool|null,supported:bool} |
| 283 |
*/ |
| 284 |
function openstation_plugins_window_field_auto_update( $row ) { |
| 285 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 286 |
if ( '' === $plugin_file ) { |
| 287 |
return array( |
| 288 |
'enabled' => false, |
| 289 |
'forced' => null, |
| 290 |
'supported' => false, |
| 291 |
); |
| 292 |
} |
| 293 |
|
| 294 |
$auto_updates = (array) get_site_option( 'auto_update_plugins', array() ); |
| 295 |
$enabled = in_array( $plugin_file, $auto_updates, true ); |
| 296 |
$supported = null !== openstation_plugins_window_update_entry( $plugin_file ); |
| 297 |
|
| 298 |
// The payload Core's filter expects (its `$filter_payload`). |
| 299 |
// `wp_is_auto_update_forced_for_item()` is admin-only, so the filter |
| 300 |
// runs directly — it is a single `apply_filters()` underneath. |
| 301 |
// `wp_parse_args( $row, $defaults )` lets `$row` win, and Core's REST |
| 302 |
// row spells `plugin` without `.php` while every `auto_update_plugin` |
| 303 |
// callback reads `$item->plugin` as the FULL filename — the |
| 304 |
// normalised file is layered on last so it always wins. |
| 305 |
$filter_payload = wp_parse_args( |
| 306 |
$row, |
| 307 |
array( |
| 308 |
'id' => $plugin_file, |
| 309 |
'slug' => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '', |
| 310 |
'plugin' => $plugin_file, |
| 311 |
'new_version' => '', |
| 312 |
'url' => '', |
| 313 |
'package' => '', |
| 314 |
'icons' => array(), |
| 315 |
'banners' => array(), |
| 316 |
'banners_rtl' => array(), |
| 317 |
'tested' => '', |
| 318 |
'requires_php' => '', |
| 319 |
'compatibility' => new stdClass(), |
| 320 |
) |
| 321 |
); |
| 322 |
$filter_payload['plugin'] = $plugin_file; |
| 323 |
$filter_payload['id'] = $plugin_file; |
| 324 |
$filter_payload = (object) $filter_payload; |
| 325 |
/** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */ |
| 326 |
$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. |
| 327 |
if ( null !== $forced ) { |
| 328 |
$forced = (bool) $forced; |
| 329 |
// A forced state is the effective state regardless of the |
| 330 |
// option — matches Core's `single_row_columns()`. |
| 331 |
$enabled = $forced; |
| 332 |
} |
| 333 |
|
| 334 |
return array( |
| 335 |
'enabled' => (bool) $enabled, |
| 336 |
'forced' => $forced, |
| 337 |
'supported' => $supported, |
| 338 |
); |
| 339 |
} |
| 340 |
|