PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / apps / plugins / plugins.os.php

plugins.os.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.8, at apps/plugins/plugins.os.php

356 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugins — the native Plugins window, as an OpenStation app.
4 *
5 * Claims the FROZEN id `desktop-mode-plugins` (see AGENTS.md), so the
6 * URL remap for `plugins.php` / `plugin-install.php`, the nonce
7 * refresh and the dock badge keep working unchanged. The window is
8 * this file; the body is `plugins.os.ts`, a client view painting the
9 * Installed table, the Browse gallery, the OpenStation-plugins
10 * gallery and the detail flyout. The installed list is `data()` — an
11 * in-process read of `/wp/v2/plugins` with every REST field the parts
12 * register — and the mutations Core serves over REST (activate /
13 * deactivate / delete) are server actions running that same
14 * controller. Install / update / upload / browse / info / reviews
15 * stay on admin-ajax (Core's handlers and `parts/ajax.php`), driven
16 * from the client with the nonces shipped in `App::config()`.
17 *
18 * (Header kept short on purpose: Plugin Check's direct-access scan
19 * reads only the first 50 raw lines, and the guard below must land
20 * inside that window.)
21 *
22 * @package OpenStation
23 */
24
25 namespace OpenStation\Apps\Plugins;
26
27 use OpenStation\App;
28 use OpenStation\App\Os;
29 use OpenStation\App\State;
30
31 // Direct access, unless a standalone host is booting on bare PHP.
32 if ( ! defined( 'ABSPATH' ) ) {
33 defined( 'OPENSTATION_STANDALONE' ) || exit;
34 }
35
36 require_once __DIR__ . '/parts/permissions.php';
37 require_once __DIR__ . '/parts/rest-fields.php';
38 require_once __DIR__ . '/parts/updates.php';
39 require_once __DIR__ . '/parts/icons.php';
40 require_once __DIR__ . '/parts/ajax.php';
41 require_once __DIR__ . '/parts/reviews.php';
42 require_once __DIR__ . '/parts/upload.php';
43 require_once __DIR__ . '/parts/featured.php';
44
45 /** The window's tabs. Browse and Featured share the `install` gate. */
46 const TABS = array( 'installed', 'browse', 'featured' );
47
48 /**
49 * Land on the tab the opener asked for (`{ tab }` in the window's
50 * params — `plugin-install.php` asks for `browse`), never on one the
51 * viewer cannot see. Runs on `mount` and again on `reopen`, when the
52 * open window is asked to open from another URL.
53 *
54 * @param State $state State.
55 * @param Os $os Host handle.
56 * @return void
57 */
58 function apply_tab( State $state, Os $os ) {
59 $tab = sanitize_key( (string) $os->param( 'tab', '' ) );
60 if ( '' === $tab || ! in_array( $tab, TABS, true ) ) {
61 return;
62 }
63 $caps = openstation_plugins_window_caps();
64 if ( 'installed' !== $tab && empty( $caps['install'] ) ) {
65 $tab = 'installed';
66 }
67 $state->set( 'tab', $tab );
68 }
69
70 /**
71 * The plugin a dispatch names, as Core's REST route wants it: the file
72 * path relative to the plugins directory without the `.php` extension
73 * (`akismet/akismet`), which is how `/wp/v2/plugins` itself keys rows.
74 *
75 * @param mixed $raw The dispatched `plugin` argument.
76 * @return string '' when unusable.
77 */
78 function plugin_path( $raw ) {
79 $plugin = is_string( $raw ) ? trim( $raw ) : '';
80 if ( '.php' === substr( $plugin, -4 ) ) {
81 $plugin = substr( $plugin, 0, -4 );
82 }
83 if ( ! preg_match( '#^[A-Za-z0-9_\-]+(?:/[A-Za-z0-9_\-]+)?$#', $plugin ) ) {
84 return '';
85 }
86 return $plugin;
87 }
88
89 /**
90 * Whether a plugin path is OpenStation itself — deactivating or
91 * deleting it leaves the shell running on a dead plugin, so the menu
92 * refresh (a hidden admin-page load that would time out) is skipped;
93 * the client navigates to the classic admin instead.
94 *
95 * @param string $plugin Plugin path without `.php`.
96 * @return bool
97 */
98 function is_self( $plugin ) {
99 $self = substr( plugin_basename( OPENSTATION_FILE ), 0, -4 );
100 return '' !== $self && $self === $plugin;
101 }
102
103 /**
104 * Run one plugin mutation through Core's REST controller — the same
105 * permission checks and the same row shape the browser would get.
106 *
107 * @param string $plugin Plugin path without `.php`.
108 * @param string $status `active` | `inactive` | `delete`.
109 * @return array{ok:bool,name:string,error:string}
110 */
111 function mutate( $plugin, $status ) {
112 // The screen gate, server-side. `openstation_plugins_window_caps()`
113 // is what hides Delete on a network — Core's site plugins screen has
114 // none, the files are the network admin's — but a super admin HOLDS
115 // `delete_plugins`, so Core's controller would let a dispatch from a
116 // stale client through. The admin-ajax half of the app enforces the
117 // same gate in its guard; this is the REST half.
118 $caps = openstation_plugins_window_caps();
119 $allowed = 'delete' === $status ? ! empty( $caps['delete'] ) : ! empty( $caps['activate'] );
120 if ( ! $allowed ) {
121 return array(
122 'ok' => false,
123 'name' => $plugin,
124 'error' => is_multisite() && 'delete' === $status
125 ? __( 'Plugins are managed from the network admin on this site.', 'desktop-mode' )
126 : __( 'You are not allowed to do that.', 'desktop-mode' ),
127 );
128 }
129 if ( 'delete' === $status ) {
130 $result = openstation_app_rest( 'DELETE', 'wp/v2/plugins/' . $plugin, array( 'force' => 'true' ) );
131 } else {
132 $result = openstation_app_rest( 'PUT', 'wp/v2/plugins/' . $plugin, array(), array( 'status' => $status ) );
133 }
134 $name = is_array( $result['data'] ) && ! empty( $result['data']['name'] ) ? (string) $result['data']['name'] : $plugin;
135 return array(
136 'ok' => (bool) $result['ok'],
137 'name' => $name,
138 'error' => (string) $result['error'],
139 );
140 }
141
142 /**
143 * A single-row mutation: the toast and the dock refresh the legacy
144 * window did after the same REST call.
145 *
146 * @param Os $os Host handle.
147 * @param array<string,mixed> $args Dispatch args (`plugin`).
148 * @param string $status `active` | `inactive` | `delete`.
149 * @return void
150 */
151 function run_single( Os $os, array $args, $status ) {
152 $plugin = plugin_path( $args['plugin'] ?? '' );
153 if ( '' === $plugin ) {
154 $os->toast( __( 'Missing plugin.', 'desktop-mode' ) );
155 return;
156 }
157 $result = mutate( $plugin, $status );
158 if ( ! $result['ok'] ) {
159 $failed = array(
160 /* translators: %s: error message */
161 'active' => __( 'Activation failed: %s', 'desktop-mode' ),
162 /* translators: %s: error message */
163 'inactive' => __( 'Deactivation failed: %s', 'desktop-mode' ),
164 /* translators: %s: error message */
165 'delete' => __( 'Delete failed: %s', 'desktop-mode' ),
166 );
167 $os->toast( sprintf( $failed[ $status ], $result['error'] ) );
168 return;
169 }
170 $done = array(
171 /* translators: %s: plugin name */
172 'active' => __( '%s activated.', 'desktop-mode' ),
173 /* translators: %s: plugin name */
174 'inactive' => __( '%s deactivated.', 'desktop-mode' ),
175 /* translators: %s: plugin name */
176 'delete' => __( '%s deleted.', 'desktop-mode' ),
177 );
178 if ( is_self( $plugin ) && 'active' !== $status ) {
179 // The client leaves for the classic admin; a menu refresh
180 // would probe a plugin that is no longer there.
181 return;
182 }
183 $os->toast( sprintf( $done[ $status ], $result['name'] ) );
184 $os->refresh_menu();
185 }
186
187 /**
188 * A bulk mutation over the selection: one request for every row, one
189 * summary toast, one dock refresh — the legacy window's serial loop.
190 *
191 * @param Os $os Host handle.
192 * @param array<string,mixed> $args Dispatch args (`plugins` list, `do`).
193 * @return void
194 */
195 function run_bulk( Os $os, array $args ) {
196 $verb = isset( $args['do'] ) ? sanitize_key( (string) $args['do'] ) : '';
197 $status = array(
198 'activate' => 'active',
199 'deactivate' => 'inactive',
200 'delete' => 'delete',
201 );
202 if ( ! isset( $status[ $verb ] ) ) {
203 $os->toast( __( 'Unknown bulk action.', 'desktop-mode' ) );
204 return;
205 }
206 $plugins = array();
207 foreach ( (array) ( $args['plugins'] ?? array() ) as $raw ) {
208 $plugin = plugin_path( $raw );
209 if ( '' !== $plugin ) {
210 $plugins[] = $plugin;
211 }
212 }
213 if ( array() === $plugins ) {
214 return;
215 }
216 $succeeded = 0;
217 $failed = 0;
218 $self_mutated = false;
219 foreach ( $plugins as $plugin ) {
220 $result = mutate( $plugin, $status[ $verb ] );
221 if ( $result['ok'] ) {
222 ++$succeeded;
223 if ( 'activate' !== $verb && is_self( $plugin ) ) {
224 $self_mutated = true;
225 }
226 } else {
227 ++$failed;
228 }
229 }
230 if ( $self_mutated ) {
231 return;
232 }
233 $nouns = array(
234 'activate' => __( 'activated', 'desktop-mode' ),
235 'deactivate' => __( 'deactivated', 'desktop-mode' ),
236 'delete' => __( 'deleted', 'desktop-mode' ),
237 );
238 if ( 0 === $failed ) {
239 /* translators: 1: count, 2: action verb (activated, deactivated, deleted) */
240 $os->toast( sprintf( __( '%1$d plugin(s) %2$s.', 'desktop-mode' ), $succeeded, $nouns[ $verb ] ) );
241 } else {
242 /* translators: 1: success count, 2: failure count, 3: action verb */
243 $os->toast( sprintf( __( '%1$d %3$s, %2$d failed.', 'desktop-mode' ), $succeeded, $failed, $nouns[ $verb ] ) );
244 }
245 $os->refresh_menu();
246 }
247
248 return App::define( 'desktop-mode-plugins' )
249 ->title( __( 'Plugins', 'desktop-mode' ) )
250 ->icon( 'dashicons-admin-plugins' )
251 ->size( 1180, 760 )
252 ->min_size( 760, 480 )
253 // `'none'` — no dock or wallpaper tile from this registration. The
254 // Plugins dock tile lives in WordPress's `$menu` and the JS-side
255 // URL remap routes its click here when the opt-in is on. A
256 // separate tile would be a duplicate entry point.
257 ->placement( 'none' )
258 // Cap-only gate so that flipping the opt-in mid-session doesn't
259 // require an F5; the opt-in is a runtime check on the JS remap.
260 ->can(
261 static function () {
262 return openstation_plugins_window_user_can_register();
263 }
264 )
265 // The static half of the config.
266 ->config(
267 array(
268 'ajaxUrl' => esc_url_raw( admin_url( 'admin-ajax.php' ) ),
269 // OpenStation's own plugin path as Core's REST controller
270 // spells it (no `.php`), so a self-deactivate is detected
271 // by comparing against the row's `plugin` field.
272 'selfPluginFile' => substr( plugin_basename( OPENSTATION_FILE ), 0, -4 ),
273 // Where the client goes after a self-deactivate: the classic
274 // Dashboard, never a reload of a possibly dead `?page=` URL.
275 'adminUrl' => esc_url_raw( admin_url() ),
276 )
277 )
278 // The per-viewer half, resolved when the manifest is built for the
279 // acting user. The client reads the nonces at call time, never from
280 // a closure: the shell's nonce refresh rewrites `ajaxNonce` /
281 // `updatesNonce` in place on this object when a session's roll.
282 ->config(
283 static function () {
284 return array(
285 'ajaxNonce' => wp_create_nonce( 'desktop-mode-plugins' ),
286 // Core's `wp_ajax_install_plugin` / `update_plugin` /
287 // `toggle_auto_updates` verify against the `'updates'`
288 // action — the string Core's wp.updates client passes.
289 'updatesNonce' => wp_create_nonce( 'updates' ),
290 'caps' => openstation_plugins_window_caps(),
291 // The global "Automatic Updates" column gate — computed
292 // on the admin page load (it needs an admin include),
293 // which is why it rides the config rather than `data()`.
294 'autoUpdatesEnabled' => openstation_plugins_window_auto_updates_enabled(),
295 );
296 }
297 )
298 ->state(
299 array(
300 'tab' => 'installed',
301 // Installed tab: status segment (`''` = all) and search.
302 'status' => '',
303 'search' => '',
304 // Browse tab: wp.org browse segment and search query.
305 'browse' => 'featured',
306 'query' => '',
307 )
308 )
309 ->mount( __NAMESPACE__ . '\apply_tab' )
310 ->action( 'reopen', __NAMESPACE__ . '\apply_tab' )
311 // The Refresh button: a fresh wp.org check (bypassing Core's 12h
312 // throttle) before `data()` re-reads the list, and the dock badge
313 // repainted from the same snapshot.
314 ->action(
315 'reload',
316 static function ( State $state, Os $os ) {
317 openstation_plugins_window_prime_updates_once( true );
318 $os->refresh_menu();
319 }
320 )
321 ->action(
322 'activate',
323 static function ( State $state, Os $os, array $args ) {
324 run_single( $os, $args, 'active' );
325 }
326 )
327 ->action(
328 'deactivate',
329 static function ( State $state, Os $os, array $args ) {
330 run_single( $os, $args, 'inactive' );
331 }
332 )
333 ->action(
334 'delete',
335 static function ( State $state, Os $os, array $args ) {
336 run_single( $os, $args, 'delete' );
337 }
338 )
339 ->action(
340 'bulk',
341 static function ( State $state, Os $os, array $args ) {
342 run_bulk( $os, $args );
343 }
344 )
345 ->data(
346 static function () {
347 // Core's `/wp/v2/plugins` doesn't paginate — the whole install
348 // in one read, every `openstation_*` field attached.
349 $result = openstation_app_rest( 'GET', 'wp/v2/plugins', array( 'context' => 'view' ) );
350 return array(
351 'installed' => $result['ok'] && is_array( $result['data'] ) ? array_values( $result['data'] ) : array(),
352 'error' => $result['ok'] ? '' : (string) $result['error'],
353 );
354 }
355 );
356