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

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

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