Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.48% covered (warning)
76.48%
322 / 421
54.55% covered (warning)
54.55%
24 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
76.48% covered (warning)
76.48%
322 / 421
54.55% covered (warning)
54.55%
24 / 44
311.38
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 is_licensing_ui_enabled
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 add_my_jetpack_menu_item
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 admin_init
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
7.19
 is_onboarding_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_onboarding_redirect_args
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 add_onboarding_admin_body_class
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 can_use_analytics
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_feature_flags
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 is_modernized
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_my_jetpack_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_onboarding_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_onboarding_takeover
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 restore_screen_id_after_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 should_load_wp_build
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 should_render_wp_build
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 maybe_load_wp_build
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 load_wp_build
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 register_wp_build_polyfills
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 enqueue_scripts
95.56% covered (success)
95.56%
86 / 90
0.00% covered (danger)
0.00%
0 / 1
8
 add_script_data
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 add_assets_script_data
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_assets_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_installed_jetpack_plugins
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_active_modules
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 is_jetpack_user_new
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 get_my_jetpack_flags
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_seo_opt_in_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 admin_page
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_rest_endpoints
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 permissions_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_initialize
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setup_historically_active_jetpack_modules_sync
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 get_site
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 get_site_info
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 is_commercial_site
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 is_registered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dismiss_welcome_banner
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 has_file_system_write_access
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
8.12
 get_idc_container_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_show_red_bubble
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
10
 enqueue_red_bubble_script
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get_recommended_modules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * WP Admin page with information and configuration shared among all Jetpack stand-alone plugins
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Assets;
12use Automattic\Jetpack\Boost_Speed_Score\Speed_Score;
13use Automattic\Jetpack\Boost_Speed_Score\Speed_Score_History;
14use Automattic\Jetpack\Connection\Client;
15use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
16use Automattic\Jetpack\Connection\Manager as Connection_Manager;
17use Automattic\Jetpack\Connection\Rest_Authentication as Connection_Rest_Authentication;
18use Automattic\Jetpack\Connection\REST_Jetpack_AI_JWT;
19use Automattic\Jetpack\Constants as Jetpack_Constants;
20use Automattic\Jetpack\ExPlat;
21use Automattic\Jetpack\Feature_Flags\Feature_Flags;
22use Automattic\Jetpack\JITMS\JITM;
23use Automattic\Jetpack\Licensing;
24use Automattic\Jetpack\Menu_Badges\Menu_Badges;
25use Automattic\Jetpack\Menu_Badges\Notification_Counts;
26use Automattic\Jetpack\Modules;
27use Automattic\Jetpack\Plugins_Installer;
28use Automattic\Jetpack\Status;
29use Automattic\Jetpack\Status\Host as Status_Host;
30use Automattic\Jetpack\Sync\Functions as Sync_Functions;
31use Automattic\Jetpack\Terms_Of_Service;
32use Automattic\Jetpack\Tracking;
33use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
34use Jetpack;
35use WP_Error;
36
37/**
38 * The main Initializer class that registers the admin menu and eneuque the assets.
39 */
40class Initializer {
41
42    /**
43     * My Jetpack package version
44     *
45     * @var string
46     */
47    const PACKAGE_VERSION = '6.2.2';
48
49    /**
50     * Feature flag that opts a site into the wp-build My Jetpack dashboard.
51     */
52    const WP_BUILD_FEATURE_FLAG = 'my-jetpack-wp-build';
53
54    /**
55     * Handle for the classic script that carries the React initial state on the
56     * wp-build path. The dashboard is a script module there, so there is no
57     * classic bundle handle to attach inline data to.
58     */
59    const DATA_SCRIPT_HANDLE = 'my-jetpack-data';
60
61    /**
62     * The wp-build page ID, which the generated enqueue check expects as the screen ID.
63     */
64    const WP_BUILD_PAGE_ID = 'my-jetpack-dashboard';
65
66    /**
67     * HTML container ID for the IDC screen on My Jetpack page.
68     */
69    private const IDC_CONTAINER_ID = 'my-jetpack-identity-crisis-container';
70
71    public const JETPACK_PLUGIN_SLUGS = array(
72        'jetpack-backup',
73        'jetpack-boost',
74        'zerobscrm',
75        'jetpack',
76        'jetpack-protect',
77        'jetpack-social',
78        'jetpack-videopress',
79        'jetpack-search',
80    );
81
82    private const MY_JETPACK_SITE_INFO_TRANSIENT_KEY = 'my-jetpack-site-info';
83
84    /**
85     * Holds info/data about the site (from the /sites/%d endpoint)
86     *
87     * @var object
88     */
89    public static $site_info;
90
91    /**
92     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
93     *
94     * @var string|null
95     */
96    private static $wp_build_original_screen_id = null;
97
98    /**
99     * Initialize My Jetpack
100     *
101     * @return void
102     */
103    public static function init() {
104        // Before the gate, so the flag stays listed while diagnosing why My Jetpack is off.
105        self::register_feature_flags();
106
107        // Before the gate: the Jetpack plugin renders this package's connection screen even
108        // where My Jetpack is off, and `myJetpackInitialState` only exists on its own page.
109        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_assets_script_data' ) );
110
111        if ( ! self::should_initialize() || did_action( 'my_jetpack_init' ) ) {
112            return;
113        }
114
115        // Answer "is this product on?" for admin menu registration.
116        Menu_Visibility::init();
117
118        // Extend jetpack plugins action links.
119        Products::extend_plugins_action_links();
120
121        // Set up the REST authentication hooks.
122        Connection_Rest_Authentication::init();
123
124        if ( self::is_licensing_ui_enabled() ) {
125            Licensing::instance()->initialize();
126        }
127
128        // Initialize Boost Speed Score
129        new Speed_Score( array(), 'jetpack-my-jetpack' );
130
131        // Add custom WP REST API endoints.
132        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
133
134        // Both of wp-build's deadlines fall later in this request: its enqueue check must be
135        // hooked before `admin_enqueue_scripts`, and its render function defined before
136        // the page callback.
137        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
138
139        add_action( 'admin_menu', array( __CLASS__, 'add_my_jetpack_menu_item' ) );
140
141        add_action( 'admin_init', array( __CLASS__, 'setup_historically_active_jetpack_modules_sync' ) );
142        // Registered on admin_menu (not admin_init) and well before priority 100000, so the
143        // counts it registers exist before the menu-badges renderer runs on admin_menu 100000.
144        add_action( 'admin_menu', array( __CLASS__, 'maybe_show_red_bubble' ), 30 );
145
146        // Set up the ExPlat package endpoints
147        ExPlat::init();
148
149        // Sets up JITMS.
150        JITM::configure();
151
152        // Add "Jetpack Manage" menu item.
153        Jetpack_Manage::init();
154
155        /**
156         * Fires after the My Jetpack package is initialized
157         *
158         * @since 0.1.0
159         */
160        do_action( 'my_jetpack_init' );
161    }
162
163    /**
164     * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
165     *
166     * @since 1.2.0
167     *
168     * @return boolean
169     */
170    public static function is_licensing_ui_enabled() {
171        // Default changed to true in 1.5.0.
172        $is_enabled = true;
173
174        /*
175         * Bail if My Jetpack is not enabled,
176         * and thus the licensing UI shouldn't be enabled either.
177         */
178        if ( ! self::should_initialize() ) {
179            $is_enabled = false;
180        }
181
182        /**
183         * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
184         *
185         * @param bool $is_enabled Defaults to true.
186         *
187         * @since 1.2.0
188         * @since 1.5.0 Update default value to true.
189         */
190        return apply_filters(
191            'jetpack_my_jetpack_should_enable_add_license_screen',
192            $is_enabled
193        );
194    }
195
196    /**
197     * Add My Jetpack menu item to the admin menu.
198     *
199     * @return void
200     */
201    public static function add_my_jetpack_menu_item() {
202        $page_suffix = Admin_Menu::add_menu(
203            __( 'My Jetpack', 'jetpack-my-jetpack' ),
204            __( 'My Jetpack', 'jetpack-my-jetpack' ),
205            'edit_posts',
206            'my-jetpack',
207            array( __CLASS__, 'admin_page' ),
208            Admin_Menu::POSITION_FIRST
209        );
210        add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
211    }
212
213    /**
214     * Callback for the load my jetpack page hook.
215     *
216     * @return void
217     */
218    public static function admin_init() {
219        $connection = new Connection_Manager();
220
221        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No nonce needed for redirect flow control
222        $step = isset( $_GET['step'] ) ? sanitize_text_field( wp_unslash( $_GET['step'] ) ) : '';
223
224        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking for partner coupon redemption flow
225        $show_coupon_redemption = isset( $_GET['showCouponRedemption'] );
226
227        // Redirect to Jetpack dashboard for partner coupon redemption
228        if ( $show_coupon_redemption ) {
229            wp_safe_redirect( admin_url( 'admin.php?page=jetpack&showCouponRedemption=1#/dashboard' ) );
230            exit( 0 );
231        }
232
233        // Handle onboarding redirects based on connection status
234        $redirect_args = self::get_onboarding_redirect_args( $step, $connection->is_connected(), self::is_onboarding_available() );
235
236        if ( null !== $redirect_args ) {
237            $admin_page = add_query_arg( $redirect_args, admin_url( 'admin.php' ) );
238            $location   = wp_sanitize_redirect( $admin_page );
239
240            // Remove wp_get_referer filter applied in `fix_redirect` method of `Jetpack_Admin` class
241            remove_filter( 'wp_redirect', 'wp_get_referer' );
242            wp_safe_redirect( $location );
243
244            exit( 0 );
245        }
246
247        // If the user reaches the onboarding page, add a class to the body
248        if ( $step === 'onboarding' ) {
249            add_filter( 'admin_body_class', array( __CLASS__, 'add_onboarding_admin_body_class' ) );
250        }
251
252        self::$site_info = self::get_site_info();
253        add_filter( 'identity_crisis_container_id', array( static::class, 'get_idc_container_id' ) );
254        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
255    }
256
257    /**
258     * Whether the My Jetpack onboarding flow is available on this site.
259     *
260     * WordPress.com Simple sites are connected by definition and don't manage their
261     * connection through My Jetpack, so the onboarding flow (which asks the user to
262     * connect) never applies there.
263     *
264     * @internal Not part of the package's public API.
265     *
266     * @return bool
267     */
268    public static function is_onboarding_available() {
269        return ! ( new Status_Host() )->is_wpcom_simple();
270    }
271
272    /**
273     * Decide whether the current My Jetpack request should redirect, and where to.
274     *
275     * @internal Not part of the package's public API.
276     *
277     * @param string $step                 The current `step` query param.
278     * @param bool   $is_connected         Whether the site is connected to WordPress.com.
279     * @param bool   $onboarding_available Whether the onboarding flow is available on this site.
280     * @return array|null Query args for the redirect, or null to stay on the current page.
281     */
282    public static function get_onboarding_redirect_args( $step, $is_connected, $onboarding_available ) {
283        if ( $onboarding_available && ! $is_connected && $step !== 'onboarding' ) {
284            // Redirect to onboarding if not connected
285            return array(
286                'page' => 'my-jetpack',
287                'step' => 'onboarding',
288            );
289        }
290
291        if ( $step === 'onboarding' && ( ! $onboarding_available || $is_connected ) ) {
292            // Redirect away from onboarding if already connected or onboarding is not available on this site
293            return array( 'page' => 'my-jetpack' );
294        }
295
296        return null;
297    }
298
299    /**
300     * Add a body class to the My Jetpack onboarding page.
301     * This class hides the WP Admin toolbar and the sidebar menu.
302     *
303     * @param string $classes The body classes.
304     * @return string The modified body classes.
305     */
306    public static function add_onboarding_admin_body_class( $classes ) {
307        $classes .= 'jetpack-admin-full-screen';
308        return $classes;
309    }
310
311    /**
312     * Returns whether we are in condition to track to use
313     * Analytics functionality like Tracks, MC, or GA.
314     */
315    public static function can_use_analytics() {
316        $status     = new Status();
317        $connection = new Connection_Manager();
318        $tracking   = new Tracking( 'jetpack', $connection );
319
320        return $tracking->should_enable_tracking( new Terms_Of_Service(), $status );
321    }
322
323    /**
324     * Register the package's feature flags.
325     *
326     * @since $$next-version$$
327     *
328     * @return void
329     */
330    public static function register_feature_flags() {
331        Feature_Flags::register(
332            self::WP_BUILD_FEATURE_FLAG,
333            array(
334                'default'     => false,
335                'description' => 'Serve the My Jetpack dashboard through the wp-build pipeline.',
336                'owner'       => 'my-jetpack',
337            )
338        );
339    }
340
341    /**
342     * Whether this site renders My Jetpack through wp-build.
343     *
344     * Defaults off while the port is verified; enable it with
345     * `wp companion feature-flag enable my-jetpack-wp-build`.
346     *
347     * @since $$next-version$$
348     *
349     * @return bool
350     */
351    public static function is_modernized() {
352        return Feature_Flags::is_enabled( self::WP_BUILD_FEATURE_FLAG );
353    }
354
355    /**
356     * Whether the current request targets the My Jetpack admin page.
357     *
358     * @since $$next-version$$
359     *
360     * @return bool
361     */
362    public static function is_my_jetpack_admin_request() {
363        if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
364            return false;
365        }
366
367        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === 'my-jetpack'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
368    }
369
370    /**
371     * Whether the current request is the full-viewport onboarding takeover.
372     *
373     * Onboarding hides all wp-admin chrome and never renders through wp-build.
374     *
375     * @since $$next-version$$
376     *
377     * @return bool
378     */
379    public static function is_onboarding_request() {
380        if ( ! isset( $_GET['step'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
381            return false;
382        }
383
384        return sanitize_text_field( wp_unslash( $_GET['step'] ) ) === 'onboarding'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
385    }
386
387    /**
388     * Whether this request is the onboarding takeover rather than the dashboard.
389     *
390     * Loading wp-build, enqueueing scripts and rendering the page must all agree,
391     * so they share this one expression.
392     *
393     * @since $$next-version$$
394     *
395     * @return bool
396     */
397    public static function is_onboarding_takeover() {
398        return self::is_onboarding_request() && self::is_onboarding_available();
399    }
400
401    /**
402     * Alias the screen ID to satisfy wp-build's generated enqueue check.
403     *
404     * @since $$next-version$$
405     *
406     * @return void
407     */
408    public static function alias_screen_id_for_wp_build() {
409        $screen = get_current_screen();
410
411        if ( ! $screen ) {
412            return;
413        }
414
415        self::$wp_build_original_screen_id = $screen->id;
416        $screen->id                        = self::WP_BUILD_PAGE_ID;
417    }
418
419    /**
420     * Undo alias_screen_id_for_wp_build(), since JITM builds its message path from the screen ID.
421     *
422     * @since $$next-version$$
423     *
424     * @return void
425     */
426    public static function restore_screen_id_after_wp_build() {
427        $screen = get_current_screen();
428
429        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
430            return;
431        }
432
433        $screen->id                        = self::$wp_build_original_screen_id;
434        self::$wp_build_original_screen_id = null;
435    }
436
437    /**
438     * Whether this request should load wp-build at all.
439     *
440     * Also what keeps WP_Build_Polyfills from replacing core scripts on every other admin page.
441     *
442     * @since $$next-version$$
443     *
444     * @return bool
445     */
446    public static function should_load_wp_build() {
447        return self::is_modernized() && self::is_my_jetpack_admin_request() && ! self::is_onboarding_takeover();
448    }
449
450    /**
451     * Whether this request renders through wp-build rather than the legacy bundle.
452     *
453     * Checks the render function too: a flag registered after `admin_menu` priority 1 leaves
454     * wp-build unloaded, and the request would otherwise get neither bundle.
455     *
456     * @since $$next-version$$
457     *
458     * @return bool
459     */
460    public static function should_render_wp_build() {
461        return self::is_modernized()
462            && ! self::is_onboarding_takeover()
463            && function_exists( 'jetpack_my_jetpack_my_jetpack_dashboard_wp_admin_render_page' );
464    }
465
466    /**
467     * Load wp-build for the My Jetpack page when the site has opted in.
468     *
469     * @since $$next-version$$
470     *
471     * @return void
472     */
473    public static function maybe_load_wp_build() {
474        if ( ! self::should_load_wp_build() ) {
475            return;
476        }
477
478        $build_index = dirname( __DIR__ ) . '/build/build.php';
479
480        if ( file_exists( $build_index ) ) {
481            self::load_wp_build( $build_index );
482        }
483    }
484
485    /**
486     * Require the generated wp-build index and wire it into this request.
487     *
488     * @since $$next-version$$
489     *
490     * @param string $build_index Path to the generated `build.php`.
491     * @return void
492     */
493    public static function load_wp_build( $build_index ) {
494        // Hooked on either side of the require, so the alias holds only for the generated
495        // enqueue check it registers at the same priority.
496        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
497        require_once $build_index;
498        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
499
500        // wp-build hooks module registration to wp_default_scripts, which has
501        // already fired by admin_menu â€” call it directly or the init module
502        // never reaches the import map.
503        if ( function_exists( 'jetpack_my_jetpack_register_script_modules' ) ) {
504            jetpack_my_jetpack_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
505        }
506
507        WP_Build_Polyfills::register(
508            'my-jetpack',
509            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
510        );
511    }
512
513    /**
514     * Register polyfills for the wp-notices / wp-private-apis / wp-rich-text / wp-theme
515     * handles the My Jetpack app bundle depends on but WP < 7.0 does not ship (or ships
516     * with an incomplete allowlist).
517     *
518     * `wp-rich-text` is needed because the bundle reaches `@wordpress/dataviews` (via
519     * `@wordpress/ui`), whose dataform controls unlock rich-text's `privateApis` at module
520     * scope. WP 6.9 exports none, so without the polyfill the bundle throws "Cannot unlock
521     * an undefined object" and the page renders blank.
522     *
523     * @return void
524     */
525    public static function register_wp_build_polyfills() {
526        if ( ! class_exists( WP_Build_Polyfills::class ) ) {
527            return;
528        }
529
530        WP_Build_Polyfills::register(
531            'my-jetpack',
532            array( 'wp-notices', 'wp-private-apis', 'wp-rich-text', 'wp-theme' )
533        );
534    }
535
536    /**
537     * Enqueue admin page assets.
538     *
539     * @return void
540     */
541    public static function enqueue_scripts() {
542        // Register the wp-build-polyfills shim before the extension hook below or
543        // the app script can enqueue against wp-theme / wp-private-apis / wp-notices.
544        // WP_Build_Polyfills registers synchronously on its first caller, so calling
545        // it after a hook consumer would leave our handles recorded but unregistered
546        // for this request.
547        self::register_wp_build_polyfills();
548
549        /**
550         * Fires after the My Jetpack page is initialized.
551         * Allows for enqueuing additional scripts only on the My Jetpack page.
552         *
553         * @since 4.35.7
554         */
555        do_action( 'myjetpack_enqueue_scripts' );
556        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
557
558        if ( self::should_render_wp_build() ) {
559            // wp-build enqueues the app itself; this empty handle exists only to
560            // print the initial state before boot runs on DOMContentLoaded.
561            $data_handle = self::DATA_SCRIPT_HANDLE;
562            wp_register_script( $data_handle, false, array(), self::PACKAGE_VERSION, true );
563            wp_enqueue_script( $data_handle );
564
565            // The i18n loader is registered on every admin page but only enqueued
566            // when depended on; the esbuild bundles don't pull it in.
567            if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
568                wp_enqueue_script( 'wp-jp-i18n-loader' );
569            }
570        } else {
571            $data_handle = 'my_jetpack_main_app';
572            Assets::register_script(
573                $data_handle,
574                '../build/index.js',
575                __FILE__,
576                array(
577                    'enqueue'    => true,
578                    'in_footer'  => true,
579                    'textdomain' => 'jetpack-my-jetpack',
580                )
581            );
582        }
583
584        $modules             = new Modules();
585        $connection          = new Connection_Manager();
586        $speed_score_history = new Speed_Score_History( get_site_url() );
587        $latest_score        = $speed_score_history->latest();
588        $previous_score      = array();
589        if ( $speed_score_history->count() > 1 ) {
590            $previous_score = $speed_score_history->latest( 1 );
591        }
592        $latest_score['previousScores'] = $previous_score['scores'] ?? array();
593
594        $sandboxed_domain = '';
595        $is_dev_version   = false;
596        if ( class_exists( 'Jetpack' ) ) {
597            $is_dev_version   = Jetpack::is_development_version();
598            $sandboxed_domain = defined( 'JETPACK__SANDBOX_DOMAIN' ) ? JETPACK__SANDBOX_DOMAIN : '';
599        }
600
601        wp_localize_script(
602            $data_handle,
603            'myJetpackInitialState',
604            array(
605                'products'               => array(
606                    'items' => Products::get_products(),
607                ),
608                'plugins'                => Plugins_Installer::get_plugins(),
609                'themes'                 => Sync_Functions::get_themes(),
610                'myJetpackUrl'           => admin_url( 'admin.php?page=my-jetpack' ),
611                'myJetpackCheckoutUri'   => admin_url( 'admin.php?page=my-jetpack' ),
612                'topJetpackMenuItemUrl'  => Admin_Menu::get_top_level_menu_item_url(),
613                'siteSuffix'             => ( new Status() )->get_site_suffix(),
614                'siteUrl'                => esc_url( get_site_url() ),
615                'blogID'                 => Connection_Manager::get_site_id( true ),
616                'myJetpackVersion'       => self::PACKAGE_VERSION,
617                'myJetpackFlags'         => self::get_my_jetpack_flags(),
618                'fileSystemWriteAccess'  => self::has_file_system_write_access(),
619                'loadAddLicenseScreen'   => self::is_licensing_ui_enabled(),
620                'adminUrl'               => esc_url( admin_url() ),
621                'assetsUrl'              => self::get_assets_url(),
622                'IDCContainerID'         => static::get_idc_container_id(),
623                'userIsAdmin'            => current_user_can( 'manage_options' ),
624                'lifecycleStats'         => array(
625                    'jetpackPlugins'            => self::get_installed_jetpack_plugins(),
626                    'historicallyActiveModules' => \Jetpack_Options::get_option( 'historically_active_modules', array() ),
627                    'brokenModules'             => Red_Bubble_Notifications::check_for_broken_modules(),
628                    'isSiteConnected'           => $connection->is_connected(),
629                    'isUserConnected'           => $connection->is_user_connected(),
630                    'modules'                   => self::get_active_modules(),
631                ),
632                'recommendedModules'     => array(
633                    'modules'    => self::get_recommended_modules(),
634                    'isFirstRun' => \Jetpack_Options::get_option( 'recommendations_first_run', true ),
635                    'dismissed'  => \Jetpack_Options::get_option( 'dismissed_recommendations', false ),
636                ),
637                'isStatsModuleActive'    => $modules->is_active( 'stats' ),
638                'canUserViewStats'       => current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ),
639                'sandboxedDomain'        => $sandboxed_domain,
640                'isDevVersion'           => $is_dev_version,
641                'isAtomic'               => ( new Status_Host() )->is_woa_site(),
642                'isJetpackPluginActive'  => class_exists( 'Jetpack' ),
643                'latestBoostSpeedScores' => $latest_score,
644                'seoOptIn'               => self::get_seo_opt_in_state(),
645            )
646        );
647
648        wp_localize_script(
649            $data_handle,
650            'myJetpackRest',
651            array(
652                'apiRoot'  => esc_url_raw( rest_url() ),
653                'apiNonce' => wp_create_nonce( 'wp_rest' ),
654            )
655        );
656
657        // Connection Initial State.
658        Connection_Initial_State::render_script( $data_handle );
659
660        // Required for Analytics.
661        if ( self::can_use_analytics() ) {
662            Tracking::register_tracks_functions_scripts( true );
663        }
664    }
665
666    /**
667     * Add My Jetpack data to the unified script data object.
668     *
669     * @param array $data The script data.
670     * @return array
671     */
672    public static function add_script_data( $data ) {
673        $block_availability = class_exists( '\Jetpack_Gutenberg' )
674            ? \Jetpack_Gutenberg::get_cached_availability()
675            : array();
676
677        $data['myJetpack']['siteEditor'] = array(
678            'isBlockTheme'            => function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(),
679            'isSharingBlockAvailable' => isset( $block_availability['sharing-buttons'] )
680                && $block_availability['sharing-buttons']['available'],
681            'isLikeBlockAvailable'    => isset( $block_availability['like'] )
682                && $block_availability['like']['available'],
683            'activeThemeStylesheet'   => get_stylesheet(),
684        );
685
686        return $data;
687    }
688
689    /**
690     * Add the package's image base URL to the admin script data.
691     *
692     * Printed on every admin page by Script_Data, so components this package exports
693     * (the connection screen) can resolve their illustrations off the My Jetpack page.
694     *
695     * @since $$next-version$$
696     *
697     * @param array $data Script data.
698     * @return array
699     */
700    public static function add_assets_script_data( $data ) {
701        $data['myJetpack']['assetsUrl'] = self::get_assets_url();
702
703        return $data;
704    }
705
706    /**
707     * Get the base URL of the package's built images, with a trailing slash.
708     *
709     * @since $$next-version$$
710     *
711     * @return string
712     */
713    public static function get_assets_url() {
714        return trailingslashit( Assets::normalize_path( plugins_url( '../build/images/', __FILE__ ) ) );
715    }
716
717    /**
718     * Get installed Jetpack plugins
719     *
720     * @return array
721     */
722    public static function get_installed_jetpack_plugins() {
723        $plugin_slugs = array_keys( Plugins_Installer::get_plugins() );
724        $plugin_slugs = array_map(
725            static function ( $slug ) {
726                $parts = explode( '/', $slug );
727                // Return the last segment of the filepath without the PHP extension
728                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
729            },
730            $plugin_slugs
731        );
732
733        return array_values( array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs ) );
734    }
735
736    /**
737     * Get active modules (except ones enabled by default)
738     *
739     * @return array
740     */
741    public static function get_active_modules() {
742        $modules        = new Modules();
743        $active_modules = $modules->get_active();
744
745        // if the Jetpack plugin is active, filter out the modules that are active by default
746        if ( class_exists( 'Jetpack' ) && ! empty( $active_modules ) ) {
747            $active_modules = array_diff( $active_modules, Jetpack::get_default_modules() );
748        }
749        return array_values( $active_modules );
750    }
751
752    /**
753     * Determine if the current user is "new" to Jetpack
754     * This is used to vary some messaging in My Jetpack
755     *
756     * On the front-end, purchases are also taken into account
757     *
758     * @return bool
759     */
760    public static function is_jetpack_user_new() {
761        // is the user connected?
762        $connection = new Connection_Manager();
763        if ( $connection->is_user_connected() ) {
764            return false;
765        }
766
767        // TODO: add a data point for the last known connection/ disconnection time
768
769        // are any modules active?
770        $active_modules = self::get_active_modules();
771        if ( ! empty( $active_modules ) ) {
772            return false;
773        }
774
775        // check for other Jetpack plugins that are installed on the site (active or not)
776        // If there's more than one Jetpack plugin active, this user is not "new"
777        $plugin_slugs              = array_keys( Plugins_Installer::get_plugins() );
778        $plugin_slugs              = array_map(
779            static function ( $slug ) {
780                $parts = explode( '/', $slug );
781                // Return the last segment of the filepath without the PHP extension
782                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
783            },
784            $plugin_slugs
785        );
786        $installed_jetpack_plugins = array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs );
787        if ( is_countable( $installed_jetpack_plugins ) && count( $installed_jetpack_plugins ) >= 2 ) {
788            return false;
789        }
790
791        // Does the site have any purchases?
792        $purchases = Wpcom_Products::get_site_current_purchases();
793        if ( ! empty( $purchases ) && ! is_wp_error( $purchases ) ) {
794            return false;
795        }
796
797        return true;
798    }
799
800    /**
801     *  Build flags for My Jetpack UI
802     *
803     *  @return array
804     */
805    public static function get_my_jetpack_flags() {
806        $flags = array(
807            'videoPressStats'          => Jetpack_Constants::is_true( 'JETPACK_MY_JETPACK_VIDEOPRESS_STATS_ENABLED' ),
808            'showFullJetpackStatsCard' => class_exists( 'Jetpack' ),
809            // Only says which destination `manage_url` is: the legacy Stats page
810            // caches its report and wants a `force_refresh` hint the dashboard does not.
811            'premiumAnalyticsEnabled'  => Products\Stats::is_premium_analytics_enabled(),
812            'showAiModuleToggle'       => Products\Jetpack_Ai::is_feature_ui_enabled(),
813        );
814
815        return $flags;
816    }
817
818    /**
819     * Build the state the My Jetpack "try the new SEO experience" opt-in card hydrates from.
820     *
821     * The card invites an existing self-hosted install to switch over to the new Jetpack SEO
822     * dashboard (JETPACK-1700). Gating lives server-side, where the signals actually are. The
823     * card shows only when all of:
824     *
825     * - the new SEO product is available â€” the `rsm_jetpack_seo` feature filter is on (the SEO
826     *   package autoloads regardless, so `class_exists()` alone isn't enough; the filter is the
827     *   real availability switch and the same one the SEO package gates its own surface behind);
828     * - the site is self-hosted â€” WordPress.com (Simple + Atomic) decides its own SEO surface, so
829     *   the opt-in card is for self-hosted installs only;
830     * - the install hasn't opted in yet â€” `jetpack_seo_surface_visible` is still false. On wpcom
831     *   the SEO package's `is_seo_surface_visible()` short-circuits to `true`, so the
832     *   "not visible yet" check also doubles as the self-hosted guard, but we check the platform
833     *   explicitly for clarity.
834     *
835     * Referenced through `class_exists()` rather than a hard composer dependency: both packages
836     * ship inside the Jetpack plugin and the SEO surface is feature-flagged, so a guarded read of
837     * its public API keeps this from adding plumbing to consumers that don't load SEO.
838     *
839     * The on-success destination is computed by the opt-in endpoint itself; we only seed the card
840     * with the same admin URL so the button has a sensible fallback before the request resolves.
841     *
842     * @return array{showCard: bool, redirect: string}
843     */
844    public static function get_seo_opt_in_state() {
845        // Guard with method_exists rather than class_exists: an older bundled SEO package can ship
846        // the Initializer class without this method, and class_exists alone would still fatal.
847        $seo_initializer = 'Automattic\Jetpack\SEO\Initializer';
848        // @phan-suppress-next-line PhanUndeclaredClassReference -- optional SEO package, guarded by method_exists.
849        $show_card = method_exists( $seo_initializer, 'is_optin_available' ) && $seo_initializer::is_optin_available();
850
851        return array(
852            'showCard' => $show_card,
853            'redirect' => admin_url( 'admin.php?page=jetpack-seo' ),
854        );
855    }
856
857    /**
858     * Echoes the admin page content.
859     *
860     * @return void
861     */
862    public static function admin_page() {
863        // No connection check needed here: admin_init() has already redirected connected users
864        // away from onboarding. Availability is re-checked inside the helper on purpose â€” this
865        // render can run even when that redirect did not.
866        $is_onboarding = self::is_onboarding_takeover();
867
868        if ( self::should_render_wp_build() ) {
869            jetpack_my_jetpack_my_jetpack_dashboard_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
870            return;
871        }
872
873        // Add data attribute for onboarding, otherwise render normal container
874        echo '<div id="my-jetpack-container" ' . ( $is_onboarding ? 'data-route="onboarding"' : '' ) . '></div>';
875    }
876
877    /**
878     * Register the REST API routes.
879     *
880     * @return void
881     */
882    public static function register_rest_endpoints() {
883        new REST_Products();
884        new REST_Purchases();
885        new REST_Zendesk_Chat();
886        ( new REST_Jetpack_AI_JWT() )->register_rest_route();
887        new REST_Recommendations_Evaluation();
888
889        Products::register_product_endpoints();
890        Historically_Active_Modules::register_rest_endpoints();
891        Jetpack_Manage::register_rest_endpoints();
892        Red_Bubble_Notifications::register_rest_endpoints();
893
894        register_rest_route(
895            'my-jetpack/v1',
896            'site',
897            array(
898                'methods'             => \WP_REST_Server::READABLE,
899                'callback'            => __CLASS__ . '::get_site',
900                'permission_callback' => __CLASS__ . '::permissions_callback',
901            )
902        );
903
904        register_rest_route(
905            'my-jetpack/v1',
906            'site/dismiss-welcome-banner',
907            array(
908                'methods'             => \WP_REST_Server::EDITABLE,
909                'callback'            => __CLASS__ . '::dismiss_welcome_banner',
910                'permission_callback' => __CLASS__ . '::permissions_callback',
911            )
912        );
913    }
914
915    /**
916     * Check user capability to access the endpoint.
917     *
918     * @access public
919     * @static
920     *
921     * @return true|WP_Error
922     */
923    public static function permissions_callback() {
924        return current_user_can( 'manage_options' );
925    }
926
927    /**
928     * Return true if we should initialize the My Jetpack admin page.
929     */
930    public static function should_initialize() {
931        $should = true;
932
933        // All options presented in My Jetpack require a connection to WordPress.com.
934        if ( ( new Status() )->is_offline_mode() ) {
935            $should = false;
936        }
937
938        /**
939         * Allows filtering whether My Jetpack should be initialized.
940         *
941         * @since 0.5.0-alpha
942         *
943         * @param bool $shoud_initialize Should we initialize My Jetpack?
944         */
945        return apply_filters( 'jetpack_my_jetpack_should_initialize', $should );
946    }
947
948    /**
949     * Hook into several connection-based actions to update the historically active Jetpack modules
950     * If the transient that indicates the list needs to be synced, update it and delete the transient
951     *
952     * @return void
953     */
954    public static function setup_historically_active_jetpack_modules_sync() {
955        // yummmm. ham.
956        $ham = new Historically_Active_Modules();
957        if ( get_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY ) && ! wp_doing_ajax() ) {
958            $ham::update_historically_active_jetpack_modules();
959            delete_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY );
960        }
961
962        $actions = array(
963            'jetpack_site_registered',
964            'jetpack_user_authorized',
965            'activated_plugin',
966        );
967
968        foreach ( $actions as $action ) {
969            add_action( $action, array( $ham, 'queue_historically_active_jetpack_modules_update' ), 5 );
970        }
971
972        // Modules are often updated async, so we need to update them right away as there will sometimes be no page reload.
973        add_action( 'jetpack_activate_module', array( $ham, 'update_historically_active_jetpack_modules' ), 5 );
974    }
975
976    /**
977     * Site full-data endpoint.
978     *
979     * @return object Site data.
980     */
981    public static function get_site() {
982        $site_id           = \Jetpack_Options::get_option( 'id' );
983        $wpcom_endpoint    = sprintf( '/sites/%d?force=wpcom', $site_id );
984        $wpcom_api_version = '1.1';
985        $response          = Client::wpcom_json_api_request_as_blog( $wpcom_endpoint, $wpcom_api_version );
986        $response_code     = wp_remote_retrieve_response_code( $response );
987        $body              = json_decode( wp_remote_retrieve_body( $response ) );
988
989        if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
990            return new WP_Error( 'site_data_fetch_failed', 'Site data fetch failed', array( 'status' => $response_code ) );
991        }
992
993        return rest_ensure_response( $body );
994    }
995
996    /**
997     * Populates the self::$site_info var with site data from the /sites/%d endpoint
998     *
999     * @return object|WP_Error
1000     */
1001    public static function get_site_info() {
1002        static $site_info = null;
1003
1004        if ( $site_info !== null ) {
1005            return $site_info;
1006        }
1007
1008        // Check for a cached value before doing lookup
1009        $stored_site_info = get_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY );
1010        if ( $stored_site_info !== false ) {
1011            return $stored_site_info;
1012        }
1013
1014        $response = self::get_site();
1015        if ( is_wp_error( $response ) ) {
1016            return $response;
1017        }
1018        $site_info = $response->data;
1019        set_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY, $site_info, DAY_IN_SECONDS );
1020
1021        return $site_info;
1022    }
1023
1024    /**
1025     * Returns whether a site has been determined "commercial" or not.
1026     *
1027     * @return bool|null
1028     */
1029    public static function is_commercial_site() {
1030        if ( is_wp_error( self::$site_info ) ) {
1031            return null;
1032        }
1033
1034        return empty( self::$site_info->options->is_commercial ) ? false : self::$site_info->options->is_commercial;
1035    }
1036
1037    /**
1038     * Check if site is registered (has been connected before).
1039     *
1040     * @return bool
1041     */
1042    public static function is_registered() {
1043        return (bool) \Jetpack_Options::get_option( 'id' );
1044    }
1045
1046    /**
1047     * Dismiss the welcome banner.
1048     *
1049     * @return \WP_REST_Response
1050     */
1051    public static function dismiss_welcome_banner() {
1052        \Jetpack_Options::update_option( 'dismissed_welcome_banner', true );
1053        return rest_ensure_response( array( 'success' => true ) );
1054    }
1055
1056    /**
1057     * Returns "yes" if the site has file write access to the plugins folder, "no" otherwise.
1058     *
1059     * @return string
1060     **/
1061    public static function has_file_system_write_access() {
1062
1063        $cache = get_transient( 'my_jetpack_write_access' );
1064
1065        if ( false !== $cache ) {
1066            return $cache;
1067        }
1068
1069        if ( ! function_exists( 'get_filesystem_method' ) ) {
1070            require_once ABSPATH . 'wp-admin/includes/file.php';
1071        }
1072
1073        require_once ABSPATH . 'wp-admin/includes/template.php';
1074
1075        $write_access = 'no';
1076
1077        $filesystem_method = get_filesystem_method( array(), WP_PLUGIN_DIR );
1078        if ( 'direct' === $filesystem_method ) {
1079            $write_access = 'yes';
1080        }
1081
1082        if ( 'no' === $write_access ) {
1083            ob_start();
1084            $filesystem_credentials_are_stored = request_filesystem_credentials( self_admin_url() );
1085            ob_end_clean();
1086
1087            if ( $filesystem_credentials_are_stored ) {
1088                $write_access = 'yes';
1089            }
1090        }
1091
1092        set_transient( 'my_jetpack_write_access', $write_access, 30 * MINUTE_IN_SECONDS );
1093
1094        return $write_access;
1095    }
1096
1097    /**
1098     * Get container IDC for the IDC screen.
1099     *
1100     * @return string
1101     */
1102    public static function get_idc_container_id() {
1103        return static::IDC_CONTAINER_ID;
1104    }
1105
1106    /**
1107     * Conditionally append the red bubble notification to the "Jetpack" menu item if there are alerts to show.
1108     *
1109     * On My Jetpack page: Uses blocking behavior to fetch fresh data.
1110     * On other admin pages: Uses cached data only to avoid blocking, with async JS fetch if cache is empty.
1111     *
1112     * @return void
1113     */
1114    public static function maybe_show_red_bubble() {
1115        global $pagenow;
1116
1117        // Don't show red bubble alerts for non-admin users
1118        // These alerts are generally only actionable for admins
1119        if ( ! current_user_can( 'manage_options' ) ) {
1120            return;
1121        }
1122
1123        // Don't show any red bubbles when Jetpack is disconnected
1124        // Users can't act on most alerts without a connection
1125        $connection = new Connection_Manager();
1126        if ( ! $connection->is_connected() ) {
1127            return;
1128        }
1129
1130        // Check if we're on the My Jetpack page.
1131        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1132        $page               = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
1133        $is_my_jetpack_page = $pagenow === 'admin.php' && $page === 'my-jetpack';
1134
1135        if ( $is_my_jetpack_page ) {
1136            // On My Jetpack page: use blocking behavior for fresh data.
1137            add_filter( 'my_jetpack_red_bubble_notification_slugs', array( Red_Bubble_Notifications::class, 'add_red_bubble_alerts' ) );
1138            $red_bubble_alerts = Red_Bubble_Notifications::get_red_bubble_alerts();
1139        } else {
1140            // On other pages: use cached data only to avoid blocking.
1141            $cached_alerts = Red_Bubble_Notifications::get_cached_alerts();
1142
1143            if ( false === $cached_alerts ) {
1144                // No cache: warm it asynchronously via JS. Register a hidden zero-count
1145                // placeholder so Menu_Renderer emits a `my-jetpack` badge element the
1146                // warmer can reveal (see async-notification-bubble.ts) without a reload.
1147                Menu_Badges::init(); // idempotent; wires the renderer.
1148                Notification_Counts::register(
1149                    'my-jetpack',
1150                    array(
1151                        'menu_slug' => 'my-jetpack',
1152                        'count'     => 0,
1153                        'type'      => 'count',
1154                    )
1155                );
1156                add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_red_bubble_script' ) );
1157                return;
1158            }
1159
1160            $red_bubble_alerts = $cached_alerts;
1161        }
1162
1163        // Filter out silent alerts.
1164        $red_bubble_alerts = array_filter(
1165            $red_bubble_alerts,
1166            function ( $alert ) {
1167                return empty( $alert['is_silent'] );
1168            }
1169        );
1170
1171        // Report each non-silent alert to the central menu-badges registry as an
1172        // attention entry (count 1). The registry + renderer own the badge.
1173        Menu_Badges::init(); // idempotent; wires the renderer.
1174        foreach ( array_keys( $red_bubble_alerts ) as $slug ) {
1175            // Protect reports its own count directly to the registry, but only when its
1176            // standalone plugin is active (see class-jetpack-protect.php::admin_page_init()).
1177            // If the standalone plugin isn't active, nobody else registers this count, so we
1178            // must not skip it here or the alert silently disappears from the menu total.
1179            if ( 'protect_has_threats' === $slug && Products\Protect::is_standalone_plugin_active() ) {
1180                continue;
1181            }
1182            Notification_Counts::register(
1183                'my-jetpack-' . $slug,
1184                array(
1185                    'menu_slug' => 'my-jetpack',
1186                    'type'      => 'attention',
1187                )
1188            );
1189        }
1190    }
1191
1192    /**
1193     * Enqueue the notification bubble script.
1194     * Fetches fresh alert data via REST API without blocking page load.
1195     *
1196     * @return void
1197     */
1198    public static function enqueue_red_bubble_script() {
1199        Assets::register_script(
1200            'my-jetpack-notification-bubble',
1201            '../build/async-notification-bubble.js',
1202            __FILE__,
1203            array(
1204                'enqueue'   => true,
1205                'in_footer' => true,
1206            )
1207        );
1208    }
1209
1210    /**
1211     * Get list of module names sorted by their recommendation score
1212     *
1213     * @return array|null
1214     */
1215    public static function get_recommended_modules() {
1216        $recommendations_evaluation = \Jetpack_Options::get_option( 'recommendations_evaluation', null );
1217
1218        if ( empty( $recommendations_evaluation ) || ! is_array( $recommendations_evaluation ) ) {
1219            return null;
1220        }
1221
1222        arsort( $recommendations_evaluation ); // Sort by scores in descending order
1223
1224        return array_keys( $recommendations_evaluation ); // Get only module names
1225    }
1226}