Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.93% covered (warning)
88.93%
538 / 605
72.58% covered (warning)
72.58%
45 / 62
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
88.93% covered (warning)
88.93%
538 / 605
72.58% covered (warning)
72.58%
45 / 62
254.33
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 use_local_module_options
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 restore_module_options
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 current_user_can_access_page
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 admin_init
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 is_onboarding_settled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_redirect_to_onboarding
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 is_onboarding_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_partner_coupon_screen
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 get_onboarding_redirect_args
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
12
 silence_onboarding_notices
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 hide_onboarding_jitms
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 add_onboarding_admin_body_class
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 can_use_analytics
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_onboarding_wizard_enabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 has_never_registered_jetpack
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_onboarding_wizard_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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
2
 should_render_wp_build
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 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
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 require_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 enqueue_scripts
96.77% covered (success)
96.77%
120 / 124
0.00% covered (danger)
0.00%
0 / 1
12
 get_header_state
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add_script_data
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 get_products_section
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add_admin_script_data
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 get_registered_admin_page_slugs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 is_admin_page_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 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
 register_feature_flags
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_my_jetpack_flags
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 is_overview_v1_enabled
100.00% covered (success)
100.00%
1 / 1
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%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 permissions_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 should_initialize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 is_offline_features_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_site_info
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
 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
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
12
 has_connection_error
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 get_alerts_counted_elsewhere
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 enqueue_red_bubble_script
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 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\Error_Handler as Connection_Error_Handler;
16use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
17use Automattic\Jetpack\Connection\Manager as Connection_Manager;
18use Automattic\Jetpack\Connection\Rest_Authentication as Connection_Rest_Authentication;
19use Automattic\Jetpack\Connection\REST_Jetpack_AI_JWT;
20use Automattic\Jetpack\Constants as Jetpack_Constants;
21use Automattic\Jetpack\ExPlat;
22use Automattic\Jetpack\Feature_Flags\Feature_Flags;
23use Automattic\Jetpack\JITMS\JITM;
24use Automattic\Jetpack\Licensing;
25use Automattic\Jetpack\Menu_Badges\Menu_Badges;
26use Automattic\Jetpack\Menu_Badges\Notification_Counts;
27use Automattic\Jetpack\Modules;
28use Automattic\Jetpack\Partner_Coupon;
29use Automattic\Jetpack\Plugins_Installer;
30use Automattic\Jetpack\Status;
31use Automattic\Jetpack\Status\Host as Status_Host;
32use Automattic\Jetpack\Sync\Functions as Sync_Functions;
33use Automattic\Jetpack\Terms_Of_Service;
34use Automattic\Jetpack\Tracking;
35use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
36use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
37use Jetpack;
38use WP_Error;
39
40/**
41 * The main Initializer class that registers the admin menu and eneuque the assets.
42 */
43class Initializer {
44
45    /**
46     * My Jetpack package version
47     *
48     * @var string
49     */
50    const PACKAGE_VERSION = '6.10.0';
51
52    /**
53     * Feature flag that swaps the single-screen onboarding takeover for the setup wizard.
54     */
55    const ONBOARDING_WIZARD_FEATURE_FLAG = 'my-jetpack-onboarding-wizard';
56
57    /**
58     * User option recording that this user chose to skip setup.
59     *
60     * Per user rather than per site, because skipping is one person saying "not
61     * now": a second admin arriving at a site nobody has set up should still be
62     * offered the flow. Finishing it is the site-wide fact, and that is
63     * ONBOARDING_COMPLETED_OPTION.
64     *
65     * A user option rather than plain user meta: `wp_usermeta` is network-wide, so
66     * without the blog prefix a skip on one site would silence setup on all of them.
67     */
68    const ONBOARDING_DISMISSED_USER_OPTION = 'jetpack_my_jetpack_onboarding_dismissed';
69
70    /**
71     * Option recording that someone has finished setup on this site.
72     *
73     * Site-wide, because the flow connects the site and switches modules on, so a
74     * second admin does not need it again. Deliberately not autoloaded.
75     */
76    const ONBOARDING_COMPLETED_OPTION = 'jetpack_my_jetpack_onboarding_completed';
77
78    /**
79     * Handle for the classic script that carries the React initial state.
80     *
81     * Plugins that render My Jetpack components on their own pages (Boost) depend on this name.
82     */
83    const DATA_SCRIPT_HANDLE = 'my_jetpack_main_app';
84
85    /**
86     * Handle for the webpack bundle that renders the onboarding takeover.
87     */
88    const ONBOARDING_SCRIPT_HANDLE = 'my_jetpack_onboarding';
89
90    /**
91     * The wp-build page ID, which the generated enqueue check expects as the screen ID.
92     */
93    const WP_BUILD_PAGE_ID = 'my-jetpack-dashboard';
94
95    /**
96     * HTML container ID for the IDC screen on My Jetpack page.
97     */
98    private const IDC_CONTAINER_ID = 'my-jetpack-identity-crisis-container';
99
100    public const JETPACK_PLUGIN_SLUGS = array(
101        'jetpack-backup',
102        'jetpack-boost',
103        'zerobscrm',
104        'jetpack',
105        'jetpack-protect',
106        'jetpack-social',
107        'jetpack-videopress',
108        'jetpack-search',
109        'jetpack-stats',
110    );
111
112    private const MY_JETPACK_SITE_INFO_TRANSIENT_KEY = 'my-jetpack-site-info';
113
114    /**
115     * Feature flag for the shared header's global controls.
116     *
117     * @var string
118     */
119    public const HEADER_CONTROLS_FLAG = 'my-jetpack-header-controls';
120
121    /**
122     * Holds info/data about the site (from the /sites/%d endpoint)
123     *
124     * @var object
125     */
126    public static $site_info;
127
128    /**
129     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
130     *
131     * @var string|null
132     */
133    private static $wp_build_original_screen_id = null;
134
135    /**
136     * Initialize My Jetpack
137     *
138     * @return void
139     */
140    public static function init() {
141        self::register_feature_flags();
142
143        // Before the gate: the Jetpack plugin renders this package's connection screen and footer
144        // links even where My Jetpack is off, and `myJetpackInitialState` only exists on its own page.
145        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_admin_script_data' ) );
146
147        if ( ! self::should_initialize() || did_action( 'my_jetpack_init' ) ) {
148            return;
149        }
150
151        if ( self::is_offline_features_enabled() ) {
152            add_filter( 'jetpack_my_jetpack_should_enable_add_license_screen', '__return_false' );
153            Connection_Rest_Authentication::init();
154            add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'use_local_module_options' ), 10, 3 );
155            add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'restore_module_options' ), 10, 3 );
156            add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
157            add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
158            add_action( 'admin_menu', array( __CLASS__, 'add_my_jetpack_menu_item' ) );
159            do_action( 'my_jetpack_init' );
160            return;
161        }
162
163        // Answer "is this product on?" for admin menu registration.
164        Menu_Visibility::init();
165
166        // Extend jetpack plugins action links.
167        Products::extend_plugins_action_links();
168
169        // Set up the REST authentication hooks.
170        Connection_Rest_Authentication::init();
171
172        if ( self::is_licensing_ui_enabled() ) {
173            Licensing::instance()->initialize();
174        }
175
176        // Initialize Boost Speed Score
177        new Speed_Score( array(), 'jetpack-my-jetpack' );
178
179        // Add custom WP REST API endoints.
180        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
181
182        // Both of wp-build's deadlines fall later in this request: its enqueue check must be
183        // hooked before `admin_enqueue_scripts`, and its render function defined before
184        // the page callback.
185        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
186
187        add_action( 'admin_menu', array( __CLASS__, 'add_my_jetpack_menu_item' ) );
188
189        add_action( 'admin_init', array( __CLASS__, 'setup_historically_active_jetpack_modules_sync' ) );
190
191        // Nothing else gets to talk over the takeover. See both methods for why it
192        // takes two hooks rather than one.
193        add_action( 'in_admin_header', array( __CLASS__, 'silence_onboarding_notices' ), PHP_INT_MAX );
194        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_onboarding_jitms' ), 10, 2 );
195        // Registered on admin_menu (not admin_init) and well before priority 100000, so the
196        // counts it registers exist before the menu-badges renderer runs on admin_menu 100000.
197        add_action( 'admin_menu', array( __CLASS__, 'maybe_show_red_bubble' ), 30 );
198
199        // Set up the ExPlat package endpoints
200        ExPlat::init();
201
202        // Sets up JITMS.
203        JITM::configure();
204
205        // Add "Jetpack Manage" menu item.
206        Jetpack_Manage::init();
207
208        /**
209         * Fires after the My Jetpack package is initialized
210         *
211         * @since 0.1.0
212         */
213        do_action( 'my_jetpack_init' );
214    }
215
216    /**
217     * Keep uncached module-list options local on the offline route.
218     *
219     * @since 6.10.0
220     * @param mixed            $response Response before the callback.
221     * @param array            $handler  Route handler.
222     * @param \WP_REST_Request $request  REST request.
223     * @return mixed
224     */
225    public static function use_local_module_options( $response, $handler, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- WordPress REST filter signature.
226        if ( '/jetpack/v4/module/all' === $request->get_route() ) {
227            add_filter( 'default_option_monitor_receive_notifications', '__return_false' );
228            add_filter( 'default_option_post_by_email_address' . get_current_user_id(), '__return_false' );
229        }
230        return $response;
231    }
232
233    /**
234     * Restore normal option reads after the offline module-list callback.
235     *
236     * @since 6.10.0
237     * @param mixed            $response Response after the callback.
238     * @param array            $handler  Route handler.
239     * @param \WP_REST_Request $request  REST request.
240     * @return mixed
241     */
242    public static function restore_module_options( $response, $handler, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- WordPress REST filter signature.
243        if ( '/jetpack/v4/module/all' === $request->get_route() ) {
244            remove_filter( 'default_option_monitor_receive_notifications', '__return_false' );
245            remove_filter( 'default_option_post_by_email_address' . get_current_user_id(), '__return_false' );
246        }
247        return $response;
248    }
249
250    /**
251     * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
252     *
253     * @since 1.2.0
254     *
255     * @return boolean
256     */
257    public static function is_licensing_ui_enabled() {
258        // Default changed to true in 1.5.0.
259        $is_enabled = true;
260
261        /*
262         * Bail if My Jetpack is not enabled,
263         * and thus the licensing UI shouldn't be enabled either.
264         */
265        if ( ! self::should_initialize() ) {
266            $is_enabled = false;
267        }
268
269        /**
270         * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
271         *
272         * @param bool $is_enabled Defaults to true.
273         *
274         * @since 1.2.0
275         * @since 1.5.0 Update default value to true.
276         */
277        return apply_filters(
278            'jetpack_my_jetpack_should_enable_add_license_screen',
279            $is_enabled
280        );
281    }
282
283    /**
284     * Add My Jetpack menu item to the admin menu.
285     *
286     * @return void
287     */
288    public static function add_my_jetpack_menu_item() {
289        if ( self::is_offline_features_enabled() && ! self::current_user_can_access_page() ) {
290            return;
291        }
292
293        $position    = defined( Admin_Menu::class . '::POSITION_FIRST' ) ? Admin_Menu::POSITION_FIRST : -10;
294        $menu_slug   = 'my-jetpack';
295        $page_suffix = Admin_Menu::add_menu(
296            __( 'My Jetpack', 'jetpack-my-jetpack' ),
297            __( 'My Jetpack', 'jetpack-my-jetpack' ),
298            'edit_posts',
299            $menu_slug,
300            array( __CLASS__, 'admin_page' ),
301            $position
302        );
303        add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
304        // Users who can edit posts but have no Jetpack menu get an admin_page_ hook instead.
305        add_action( 'load-admin_page_' . $menu_slug, array( __CLASS__, 'admin_init' ) );
306    }
307
308    /**
309     * Whether the current user may access the page selected by My Jetpack.
310     *
311     * @since 6.10.0
312     * @return bool
313     */
314    public static function current_user_can_access_page() {
315        return current_user_can( 'edit_posts' )
316            && ( ! self::is_offline_features_enabled() || REST_Main_Features::permissions_callback() );
317    }
318
319    /**
320     * Callback for the load my jetpack page hook.
321     *
322     * @return void
323     */
324    public static function admin_init() {
325        if ( self::is_offline_features_enabled() ) {
326            if ( ! self::current_user_can_access_page() ) {
327                wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'jetpack-my-jetpack' ), 403 );
328            }
329            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
330            return;
331        }
332
333        self::maybe_redirect_to_onboarding();
334
335        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No nonce needed for redirect flow control
336        $step = isset( $_GET['step'] ) ? sanitize_text_field( wp_unslash( $_GET['step'] ) ) : '';
337
338        // If the user reaches the onboarding page, add a class to the body
339        if ( $step === 'onboarding' ) {
340            add_filter( 'admin_body_class', array( __CLASS__, 'add_onboarding_admin_body_class' ) );
341        }
342
343        self::$site_info = self::get_site_info();
344        add_filter( 'identity_crisis_container_id', array( static::class, 'get_idc_container_id' ) );
345        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
346    }
347
348    /**
349     * Whether this user has already settled setup, one way or the other.
350     *
351     * Two separate facts. Finishing is site-wide: the wizard connects the site and
352     * switches modules on, so once it is done nobody needs the takeover again.
353     * Skipping is this person saying "not now", and it must not answer for the next
354     * admin who arrives at a site nobody has set up.
355     *
356     * @internal Not part of the package's public API.
357     *
358     * @return bool
359     */
360    public static function is_onboarding_settled() {
361        if ( get_option( self::ONBOARDING_COMPLETED_OPTION, false ) ) {
362            return true;
363        }
364
365        return (bool) get_user_option( self::ONBOARDING_DISMISSED_USER_OPTION );
366    }
367
368    /**
369     * Send the user into setup when they land on My Jetpack without one.
370     *
371     * Runs from My Jetpack's own page load, which nothing but a request for that page
372     * reaches and which fires before any output, so it needs no guards of its own.
373     *
374     * @return void
375     */
376    public static function maybe_redirect_to_onboarding() {
377        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No nonce needed for redirect flow control
378        $step = isset( $_GET['step'] ) ? sanitize_text_field( wp_unslash( $_GET['step'] ) ) : '';
379
380        $wizard_enabled = self::is_onboarding_wizard_enabled();
381        $connection     = new Connection_Manager();
382
383        // Only the wizard connects a user, so only it may count a site-only connection as
384        // unfinished setup. The single screen can only register the site, so asking it for
385        // an owner leaves that site on a screen with no way back out.
386        $connection_done = $wizard_enabled ? $connection->has_connected_owner() : $connection->is_connected();
387
388        // Onboarding's only action is the connect request, which answers a user
389        // without `jetpack_connect` with a 403.
390        $redirect_args = self::get_onboarding_redirect_args(
391            $step,
392            $connection_done,
393            self::is_onboarding_available() && current_user_can( 'jetpack_connect' ),
394            $wizard_enabled,
395            // Only the wizard records a settled run, and only a registered site may act on one:
396            // My Jetpack requires the site connection that accepting the terms creates.
397            $wizard_enabled && $connection->is_connected() && self::is_onboarding_settled()
398        );
399
400        if ( null === $redirect_args ) {
401            return;
402        }
403
404        $location = wp_sanitize_redirect( add_query_arg( $redirect_args, admin_url( 'admin.php' ) ) );
405
406        // Remove wp_get_referer filter applied in `fix_redirect` method of `Jetpack_Admin` class
407        remove_filter( 'wp_redirect', 'wp_get_referer' );
408        wp_safe_redirect( $location );
409
410        exit( 0 );
411    }
412
413    /**
414     * Whether the My Jetpack onboarding flow is available on this site.
415     *
416     * WordPress.com Simple sites are connected by definition and don't manage their
417     * connection through My Jetpack, so the onboarding flow (which asks the user to
418     * connect) never applies there. A pending partner coupon brings its own connect screen.
419     *
420     * @internal Not part of the package's public API.
421     *
422     * @return bool
423     */
424    public static function is_onboarding_available() {
425        if ( self::is_offline_features_enabled() ) {
426            return false;
427        }
428        return ! ( new Status_Host() )->is_wpcom_simple() && null === self::get_partner_coupon_screen();
429    }
430
431    /**
432     * The partner coupon screen's data, when it should replace the dashboard.
433     *
434     * Coupons exist only with the Jetpack plugin, which supplies their products and images.
435     *
436     * @since 6.5.0
437     *
438     * @return array{coupon: array, assetBaseUrl: string}|null
439     */
440    public static function get_partner_coupon_screen() {
441        if (
442            self::is_offline_features_enabled()
443            || ! self::should_initialize()
444            || ! current_user_can( 'manage_options' )
445            || ! Jetpack_Constants::is_defined( 'JETPACK__PLUGIN_FILE' )
446        ) {
447            return null;
448        }
449
450        $coupon = Partner_Coupon::get_coupon();
451        if ( ! $coupon ) {
452            return null;
453        }
454
455        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks which screen renders.
456        if ( ! isset( $_GET['showCouponRedemption'] ) && ( new Connection_Manager() )->has_connected_owner() ) {
457            return null;
458        }
459
460        return array(
461            'coupon'       => $coupon,
462            'assetBaseUrl' => plugins_url( '', Jetpack_Constants::get_constant( 'JETPACK__PLUGIN_FILE' ) ),
463        );
464    }
465
466    /**
467     * Decide whether the current My Jetpack request should redirect, and where to.
468     *
469     * @internal Not part of the package's public API.
470     *
471     * @param string $step                 The current `step` query param.
472     * @param bool   $connection_done      Whether the connection the takeover asks for is in place:
473     *                                     a connected owner with the wizard, a connected site without it.
474     * @param bool   $onboarding_available Whether the onboarding flow is available on this site.
475     * @param bool   $wizard_enabled       Whether the takeover renders the setup wizard.
476     * @param bool   $is_settled           Whether this user has already finished or skipped setup.
477     * @return array|null Query args for the redirect, or null to stay on the current page.
478     */
479    public static function get_onboarding_redirect_args( $step, $connection_done, $onboarding_available, $wizard_enabled = false, $is_settled = false ) {
480        /*
481         * Only the automatic redirect is suppressed once setup is settled. Asking for
482         * the onboarding page by its own URL still works, so someone who skipped can
483         * go back to it, and so can a second admin the site-wide flag does not cover.
484         */
485        if ( $onboarding_available && ! $connection_done && ! $is_settled && $step !== 'onboarding' ) {
486            // The connection setup asks for is not in place, so setup is unfinished.
487            return array(
488                'page' => 'my-jetpack',
489                'step' => 'onboarding',
490            );
491        }
492
493        // The wizard connects mid-flow: the user authorizes on WordPress.com and is sent
494        // back here to finish setup, so arriving connected is not the same as finishing.
495        if ( $step === 'onboarding' && $connection_done && $onboarding_available && $wizard_enabled ) {
496            return null;
497        }
498
499        if ( $step === 'onboarding' && ( ! $onboarding_available || $connection_done ) ) {
500            // Nothing left to set up here, or onboarding does not apply on this site.
501            return array( 'page' => 'my-jetpack' );
502        }
503
504        return null;
505    }
506
507    /**
508     * Empty the notice channels while the takeover is on screen.
509     *
510     * The takeover hides `.notice`, `.error` and `.updated` in CSS, which only
511     * catches notices that use WordPress's own classes. Plenty do not, and a plugin
512     * announcing itself in its own markup lands on top of a full-screen flow that
513     * has no way to scroll past it.
514     *
515     * On `in_admin_header`, the last hook before `admin-header.php` prints notices,
516     * so a notice registered late is caught too.
517     *
518     * @return void
519     */
520    public static function silence_onboarding_notices() {
521        if ( ! self::is_onboarding_request() ) {
522            return;
523        }
524
525        remove_all_actions( 'admin_notices' );
526        remove_all_actions( 'all_admin_notices' );
527        remove_all_actions( 'network_admin_notices' );
528        remove_all_actions( 'user_admin_notices' );
529    }
530
531    /**
532     * Keep Just In Time Messages off the takeover.
533     *
534     * Emptying the notice channels above would also drop the JITM, since that is how
535     * it renders, but only after it has enqueued its script and stylesheet. This is
536     * the package's own opt-out and it runs before any of that.
537     *
538     * The screen id cannot answer this on its own: the takeover and the My Jetpack
539     * dashboard are the same screen, and only the query arg separates them.
540     *
541     * @param bool   $show      Whether to show JITMs on this screen.
542     * @param string $screen_id The current screen's id.
543     * @return bool
544     */
545    public static function hide_onboarding_jitms( $show, $screen_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required by the filter signature.
546        return self::is_onboarding_request() ? false : $show;
547    }
548
549    /**
550     * Add a body class to the My Jetpack onboarding page.
551     * This class hides the WP Admin toolbar and the sidebar menu.
552     *
553     * @param string $classes The body classes.
554     * @return string The modified body classes.
555     */
556    public static function add_onboarding_admin_body_class( $classes ) {
557        $classes .= ' jetpack-admin-full-screen';
558        return $classes;
559    }
560
561    /**
562     * Returns whether we are in condition to track to use
563     * Analytics functionality like Tracks, MC, or GA.
564     */
565    public static function can_use_analytics() {
566        $status     = new Status();
567        $connection = new Connection_Manager();
568        $tracking   = new Tracking( 'jetpack', $connection );
569
570        return $tracking->should_enable_tracking( new Terms_Of_Service(), $status );
571    }
572
573    /**
574     * Whether the onboarding takeover renders the setup wizard.
575     *
576     * Self-hosted only. On WordPress.com the platform owns the connection and nobody chose
577     * to install Jetpack, so a screen inviting them to start with it and connect an account
578     * does not apply. `is_wpcom_platform()` covers Simple and WordPress.com on Atomic, and
579     * requires wpcomsh, so other Atomic-hosted products still count as self-hosted here.
580     *
581     * @since $$next-version$$
582     *
583     * @return bool
584     */
585    public static function is_onboarding_wizard_enabled() {
586        if ( ( new Status_Host() )->is_wpcom_platform() ) {
587            return false;
588        }
589
590        if ( ! Feature_Flags::is_enabled( self::ONBOARDING_WIZARD_FEATURE_FLAG ) ) {
591            return false;
592        }
593
594        // The wizard registers the site at its own first step, which writes the marker
595        // below, so asking again on the way back from WordPress.com would drop someone
596        // out of a run already under way.
597        if ( ( new Connection_Manager() )->is_connected() ) {
598            return true;
599        }
600
601        return self::has_never_registered_jetpack();
602    }
603
604    /**
605     * Whether the Jetpack plugin has never registered this site.
606     *
607     * `unique_registrations` is written on `jetpack_site_registered` by the Jetpack plugin
608     * alone, so a site that has only ever run a standalone plugin still reads as new here.
609     *
610     * @internal Not part of the package's public API.
611     *
612     * @since $$next-version$$
613     *
614     * @return bool
615     */
616    public static function has_never_registered_jetpack() {
617        return ! \Jetpack_Options::get_option( 'unique_registrations' );
618    }
619
620    /**
621     * The wizard's configuration, or null while the takeover stays on its single screen.
622     *
623     * @since $$next-version$$
624     *
625     * @return array{exitUrl: string, dashboardUrl: string}|null
626     */
627    public static function get_onboarding_wizard_state() {
628        if ( ! self::is_onboarding_wizard_enabled() ) {
629            return null;
630        }
631
632        return array(
633            // Where skipping the wizard lands: the admin menu is hidden, so leaving is always one click.
634            'exitUrl'      => admin_url( 'admin.php?page=my-jetpack' ),
635            // Where leaving Jetpack altogether lands: wp-admin's own dashboard.
636            'dashboardUrl' => admin_url(),
637        );
638    }
639
640    /**
641     * Whether the current request targets the My Jetpack admin page.
642     *
643     * @since 6.3.0
644     *
645     * @return bool
646     */
647    public static function is_my_jetpack_admin_request() {
648        if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
649            return false;
650        }
651
652        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === 'my-jetpack'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
653    }
654
655    /**
656     * Whether the current request is the full-viewport onboarding takeover.
657     *
658     * Onboarding hides all wp-admin chrome and never renders through wp-build. The page
659     * is part of the question: `in_admin_header` and the JITM filter run everywhere.
660     *
661     * @since 6.3.0
662     *
663     * @return bool
664     */
665    public static function is_onboarding_request() {
666        if ( ! self::is_my_jetpack_admin_request() ) {
667            return false;
668        }
669
670        if ( ! isset( $_GET['step'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
671            return false;
672        }
673
674        return sanitize_text_field( wp_unslash( $_GET['step'] ) ) === 'onboarding'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
675    }
676
677    /**
678     * Whether this request is the onboarding takeover rather than the dashboard.
679     *
680     * Loading wp-build, enqueueing scripts and rendering the page must all agree,
681     * so they share this one expression.
682     *
683     * @since 6.3.0
684     *
685     * @return bool
686     */
687    public static function is_onboarding_takeover() {
688        return self::is_onboarding_request() && self::is_onboarding_available();
689    }
690
691    /**
692     * Alias the screen ID to satisfy wp-build's generated enqueue check.
693     *
694     * @since 6.3.0
695     *
696     * @return void
697     */
698    public static function alias_screen_id_for_wp_build() {
699        $screen = get_current_screen();
700
701        if ( ! $screen ) {
702            return;
703        }
704
705        self::$wp_build_original_screen_id = $screen->id;
706        $screen->id                        = self::WP_BUILD_PAGE_ID;
707    }
708
709    /**
710     * Undo alias_screen_id_for_wp_build(), since JITM builds its message path from the screen ID.
711     *
712     * @since 6.3.0
713     *
714     * @return void
715     */
716    public static function restore_screen_id_after_wp_build() {
717        $screen = get_current_screen();
718
719        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
720            return;
721        }
722
723        $screen->id                        = self::$wp_build_original_screen_id;
724        self::$wp_build_original_screen_id = null;
725    }
726
727    /**
728     * Whether this request should load wp-build at all.
729     *
730     * Also what keeps WP_Build_Polyfills from replacing core scripts on every other admin page.
731     *
732     * @since 6.3.0
733     *
734     * @return bool
735     */
736    public static function should_load_wp_build() {
737        return self::is_my_jetpack_admin_request() && ! self::is_onboarding_takeover();
738    }
739
740    /**
741     * Whether this request renders the dashboard through wp-build.
742     *
743     * The generated render function is missing where the package was never built.
744     *
745     * @since 6.3.0
746     *
747     * @return bool
748     */
749    public static function should_render_wp_build() {
750        return ! self::is_onboarding_takeover()
751            && function_exists( 'jetpack_my_jetpack_my_jetpack_dashboard_wp_admin_render_page' );
752    }
753
754    /**
755     * Load wp-build for the My Jetpack dashboard.
756     *
757     * @since 6.3.0
758     *
759     * @return void
760     */
761    public static function maybe_load_wp_build() {
762        if ( ! self::should_load_wp_build() ) {
763            return;
764        }
765
766        $build_index = dirname( __DIR__ ) . '/build/build.php';
767
768        if ( file_exists( $build_index ) ) {
769            self::load_wp_build( $build_index );
770        }
771    }
772
773    /**
774     * Require the generated wp-build index and wire it into this request.
775     *
776     * @since 6.3.0
777     *
778     * @param string $build_index Path to the generated `build.php`.
779     * @return void
780     */
781    public static function load_wp_build( $build_index ) {
782        self::require_wp_build_with_screen_alias( $build_index );
783
784        // wp-build hooks module registration to wp_default_scripts, which has
785        // already fired by admin_menu â€” call it directly or the init module
786        // never reaches the import map.
787        if ( function_exists( 'jetpack_my_jetpack_register_script_modules' ) ) {
788            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.
789        }
790
791        WP_Build_Polyfills::register(
792            'my-jetpack',
793            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
794        );
795    }
796
797    /**
798     * Require the generated build file with the screen ID aliased across its enqueue check.
799     *
800     * @see WP_Build_Screen_Id::load_with_alias()
801     * @param string $build_index Path to the generated `build.php`.
802     * @return void
803     */
804    private static function require_wp_build_with_screen_alias( $build_index ) {
805        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
806        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
807            WP_Build_Screen_Id::load_with_alias(
808                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
809                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
810                function () use ( $build_index ) {
811                    require_once $build_index;
812                }
813            );
814            return;
815        }
816
817        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
818        require_once $build_index;
819        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
820    }
821
822    /**
823     * Enqueue admin page assets.
824     *
825     * @return void
826     */
827    public static function enqueue_scripts() {
828        if ( self::is_offline_features_enabled() ) {
829            $data_handle = self::DATA_SCRIPT_HANDLE;
830            wp_register_script( $data_handle, false, array(), self::PACKAGE_VERSION, true );
831            wp_enqueue_script( $data_handle );
832            add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
833            wp_localize_script(
834                $data_handle,
835                'myJetpackInitialState',
836                array(
837                    'isOfflineFeatures'     => true,
838                    'products'              => array( 'items' => array() ),
839                    'adminUrl'              => esc_url( admin_url() ),
840                    'assetsUrl'             => self::get_assets_url(),
841                    'isJetpackPluginActive' => class_exists( 'Jetpack' ),
842                    'hiddenFeatures'        => Feature_Visibility::get_hidden(),
843                    'myJetpackFlags'        => self::get_my_jetpack_flags(),
844                )
845            );
846            wp_localize_script(
847                $data_handle,
848                'myJetpackRest',
849                array(
850                    'apiRoot'  => esc_url_raw( rest_url() ),
851                    'apiNonce' => wp_create_nonce( 'wp_rest' ),
852                )
853            );
854            if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
855                wp_enqueue_script( 'wp-jp-i18n-loader' );
856            }
857            return;
858        }
859
860        /**
861         * Fires after the My Jetpack page is initialized.
862         * Allows for enqueuing additional scripts only on the My Jetpack page.
863         *
864         * @since 4.35.7
865         */
866        do_action( 'myjetpack_enqueue_scripts' );
867        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
868
869        // Script-less: it only prints the state below, before the dashboard's boot runs on DOMContentLoaded.
870        $data_handle = self::DATA_SCRIPT_HANDLE;
871        wp_register_script( $data_handle, false, array(), self::PACKAGE_VERSION, true );
872        wp_enqueue_script( $data_handle );
873
874        if ( self::is_my_jetpack_admin_request() && self::is_onboarding_takeover() ) {
875            Assets::register_script(
876                self::ONBOARDING_SCRIPT_HANDLE,
877                '../build/onboarding.js',
878                __FILE__,
879                array(
880                    'dependencies' => array( $data_handle ),
881                    'enqueue'      => true,
882                    'in_footer'    => true,
883                    'textdomain'   => 'jetpack-my-jetpack',
884                )
885            );
886        } elseif ( self::should_render_wp_build() && wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
887            // Registered on every admin page but only enqueued when depended on; the esbuild bundles don't pull it in.
888            wp_enqueue_script( 'wp-jp-i18n-loader' );
889        }
890
891        $modules             = new Modules();
892        $connection          = new Connection_Manager();
893        $speed_score_history = new Speed_Score_History( get_site_url() );
894        $latest_score        = $speed_score_history->latest();
895        $previous_score      = array();
896        if ( $speed_score_history->count() > 1 ) {
897            $previous_score = $speed_score_history->latest( 1 );
898        }
899        $latest_score['previousScores'] = $previous_score['scores'] ?? array();
900
901        $sandboxed_domain = '';
902        $is_dev_version   = false;
903        if ( class_exists( 'Jetpack' ) ) {
904            $is_dev_version   = Jetpack::is_development_version();
905            $sandboxed_domain = defined( 'JETPACK__SANDBOX_DOMAIN' ) ? JETPACK__SANDBOX_DOMAIN : '';
906        }
907
908        wp_localize_script(
909            $data_handle,
910            'myJetpackInitialState',
911            array(
912                'products'               => array(
913                    'items' => Products::get_products(),
914                ),
915                'mainFeatures'           => Main_Features::get_state(),
916                'featuresBanner'         => array( 'isDismissed' => REST_Main_Features::is_banner_dismissed() ),
917                'onboardingWizard'       => self::get_onboarding_wizard_state(),
918                'header'                 => self::get_header_state(),
919                'plugins'                => Plugins_Installer::get_plugins(),
920                'themes'                 => Sync_Functions::get_themes(),
921                'myJetpackUrl'           => admin_url( 'admin.php?page=my-jetpack' ),
922                'myJetpackCheckoutUri'   => admin_url( 'admin.php?page=my-jetpack' ),
923                'topJetpackMenuItemUrl'  => Admin_Menu::get_top_level_menu_item_url(),
924                'siteSuffix'             => ( new Status() )->get_site_suffix(),
925                'siteUrl'                => esc_url( get_site_url() ),
926                'blogID'                 => Connection_Manager::get_site_id( true ),
927                'myJetpackVersion'       => self::PACKAGE_VERSION,
928                'myJetpackFlags'         => self::get_my_jetpack_flags(),
929                'fileSystemWriteAccess'  => self::has_file_system_write_access(),
930                'loadAddLicenseScreen'   => self::is_licensing_ui_enabled(),
931                'adminUrl'               => esc_url( admin_url() ),
932                'assetsUrl'              => self::get_assets_url(),
933                'IDCContainerID'         => static::get_idc_container_id(),
934                'userIsAdmin'            => current_user_can( 'manage_options' ),
935                'lifecycleStats'         => array(
936                    'jetpackPlugins'            => self::get_installed_jetpack_plugins(),
937                    'historicallyActiveModules' => \Jetpack_Options::get_option( 'historically_active_modules', array() ),
938                    'brokenModules'             => Red_Bubble_Notifications::check_for_broken_modules(),
939                    'isSiteConnected'           => $connection->is_connected(),
940                    'isUserConnected'           => $connection->is_user_connected(),
941                    'modules'                   => self::get_active_modules(),
942                ),
943                'recommendedModules'     => array(
944                    'modules'    => self::get_recommended_modules(),
945                    'isFirstRun' => \Jetpack_Options::get_option( 'recommendations_first_run', true ),
946                    'dismissed'  => \Jetpack_Options::get_option( 'dismissed_recommendations', false ),
947                ),
948                'isStatsModuleActive'    => $modules->is_active( 'stats' ),
949                'canUserViewStats'       => current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ),
950                'hiddenFeatures'         => Feature_Visibility::get_hidden(),
951                'sandboxedDomain'        => $sandboxed_domain,
952                'isDevVersion'           => $is_dev_version,
953                'isAtomic'               => ( new Status_Host() )->is_woa_site(),
954                'isJetpackPluginActive'  => class_exists( 'Jetpack' ),
955                'latestBoostSpeedScores' => $latest_score,
956                'seoOptIn'               => self::get_seo_opt_in_state(),
957                'partnerCoupon'          => self::get_partner_coupon_screen(),
958            )
959        );
960
961        wp_localize_script(
962            $data_handle,
963            'myJetpackRest',
964            array(
965                'apiRoot'  => esc_url_raw( rest_url() ),
966                'apiNonce' => wp_create_nonce( 'wp_rest' ),
967            )
968        );
969
970        // Connection Initial State.
971        Connection_Initial_State::render_script( $data_handle );
972
973        // Required for Analytics.
974        if ( self::can_use_analytics() ) {
975            Tracking::register_tracks_functions_scripts( true );
976        }
977    }
978
979    /**
980     * What the shared header needs beyond the rest of the state, so it renders without a request.
981     *
982     * @since 6.10.0
983     *
984     * @return array{connectorsUrl: string|null} The state.
985     */
986    public static function get_header_state() {
987        return array(
988            // The same probe the connection package uses: the core screen ships with WordPress 7.0.
989            'connectorsUrl' => file_exists( ABSPATH . 'wp-admin/options-connectors.php' ) ? admin_url( 'options-connectors.php' ) : null,
990        );
991    }
992
993    /**
994     * Add My Jetpack data to the unified script data object.
995     *
996     * @param array $data The script data.
997     * @return array
998     */
999    public static function add_script_data( $data ) {
1000        $block_availability = ! self::is_offline_features_enabled() && class_exists( '\Jetpack_Gutenberg' )
1001            ? \Jetpack_Gutenberg::get_cached_availability()
1002            : array();
1003
1004        $data['myJetpack']['siteEditor'] = array(
1005            'isBlockTheme'            => function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(),
1006            'isSharingBlockAvailable' => isset( $block_availability['sharing-buttons'] )
1007                && $block_availability['sharing-buttons']['available'],
1008            'isLikeBlockAvailable'    => isset( $block_availability['like'] )
1009                && $block_availability['like']['available'],
1010            'activeThemeStylesheet'   => get_stylesheet(),
1011        );
1012
1013        return $data;
1014    }
1015
1016    /**
1017     * Get the slug and label of the Features tab, for footer links to it.
1018     *
1019     * Kept for footers built before the Products tab was removed: Jetpack 16.3 calls this, and
1020     * older JS footers read it from script data. Without it they link to "Products".
1021     *
1022     * @since 6.4.0
1023     *
1024     * @return array{slug: string, label: string}
1025     */
1026    public static function get_products_section() {
1027        return array(
1028            'slug'  => 'features',
1029            'label' => _x( 'Features', 'Navigation item', 'jetpack-my-jetpack' ),
1030        );
1031    }
1032
1033    /**
1034     * Add My Jetpack's availability, image base URL and link targets to admin script data.
1035     *
1036     * Printed on every admin page by Script_Data, so the connection screen can resolve its
1037     * illustrations and Jetpack footers know whether to link to My Jetpack.
1038     *
1039     * @since 6.3.0
1040     *
1041     * @param array $data Script data.
1042     * @return array
1043     */
1044    public static function add_admin_script_data( $data ) {
1045        $data['myJetpack']['isAvailable']     = self::is_admin_page_available();
1046        $data['myJetpack']['assetsUrl']       = self::get_assets_url();
1047        $data['myJetpack']['productsSection'] = self::get_products_section();
1048
1049        // Only administrators can open the pages this points at, so spare everyone else the lookup.
1050        if ( current_user_can( 'manage_options' ) ) {
1051            $data['myJetpack']['aiManageUrl'] = esc_url_raw( Products\Jetpack_Ai::get_manage_url() );
1052        }
1053
1054        if (
1055            self::is_my_jetpack_admin_request()
1056            && self::is_offline_features_enabled()
1057            && self::should_initialize()
1058            && REST_Main_Features::permissions_callback()
1059        ) {
1060            $data['myJetpack']['offlineFeatures'] = array(
1061                'mainFeatures' => Main_Features::get_state( true ),
1062                'plugins'      => Plugins_Installer::get_plugins(),
1063            );
1064            $data['myJetpack']['offlineFeatures']['mainFeatures']['available_admin_pages'] = self::get_registered_admin_page_slugs();
1065        }
1066
1067        return $data;
1068    }
1069
1070    /**
1071     * Get the slugs of registered admin pages.
1072     *
1073     * Skip hooks containing `?`: they are `edit.php?post_type=...` style pages, not page slugs.
1074     *
1075     * @return string[]
1076     */
1077    private static function get_registered_admin_page_slugs() {
1078        global $_registered_pages;
1079
1080        $hooks = array_filter(
1081            array_keys( $_registered_pages ?? array() ),
1082            static fn( $hook ) => false !== strpos( $hook, '_page_' ) && false === strpos( $hook, '?' )
1083        );
1084
1085        return array_values(
1086            array_unique(
1087                array_map( static fn( $hook ) => substr( $hook, strpos( $hook, '_page_' ) + 6 ), $hooks )
1088            )
1089        );
1090    }
1091
1092    /**
1093     * Whether My Jetpack's admin page is available to the current user.
1094     *
1095     * Meaningful only after admin_menu has registered the page.
1096     *
1097     * @since 6.5.0
1098     *
1099     * @return bool
1100     */
1101    public static function is_admin_page_available() {
1102        global $_registered_pages;
1103
1104        return did_action( 'my_jetpack_init' ) > 0
1105            && isset( $_registered_pages[ get_plugin_page_hookname( 'my-jetpack', 'jetpack' ) ] )
1106            && current_user_can( 'edit_posts' );
1107    }
1108
1109    /**
1110     * Get the base URL of the package's built images, with a trailing slash.
1111     *
1112     * @since 6.3.0
1113     *
1114     * @return string
1115     */
1116    public static function get_assets_url() {
1117        return trailingslashit( Assets::normalize_path( plugins_url( '../build/images/', __FILE__ ) ) );
1118    }
1119
1120    /**
1121     * Get installed Jetpack plugins
1122     *
1123     * @return array
1124     */
1125    public static function get_installed_jetpack_plugins() {
1126        $plugin_slugs = array_keys( Plugins_Installer::get_plugins() );
1127        $plugin_slugs = array_map(
1128            static function ( $slug ) {
1129                $parts = explode( '/', $slug );
1130                // Return the last segment of the filepath without the PHP extension
1131                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1132            },
1133            $plugin_slugs
1134        );
1135
1136        return array_values( array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs ) );
1137    }
1138
1139    /**
1140     * Get active modules (except ones enabled by default)
1141     *
1142     * @return array
1143     */
1144    public static function get_active_modules() {
1145        $modules        = new Modules();
1146        $active_modules = $modules->get_active();
1147
1148        // if the Jetpack plugin is active, filter out the modules that are active by default
1149        if ( class_exists( 'Jetpack' ) && ! empty( $active_modules ) ) {
1150            $active_modules = array_diff( $active_modules, Jetpack::get_default_modules() );
1151        }
1152        return array_values( $active_modules );
1153    }
1154
1155    /**
1156     * Determine if the current user is "new" to Jetpack
1157     * This is used to vary some messaging in My Jetpack
1158     *
1159     * On the front-end, purchases are also taken into account
1160     *
1161     * @return bool
1162     */
1163    public static function is_jetpack_user_new() {
1164        // is the user connected?
1165        $connection = new Connection_Manager();
1166        if ( $connection->is_user_connected() ) {
1167            return false;
1168        }
1169
1170        // TODO: add a data point for the last known connection/ disconnection time
1171
1172        // are any modules active?
1173        $active_modules = self::get_active_modules();
1174        if ( ! empty( $active_modules ) ) {
1175            return false;
1176        }
1177
1178        // check for other Jetpack plugins that are installed on the site (active or not)
1179        // If there's more than one Jetpack plugin active, this user is not "new"
1180        $plugin_slugs              = array_keys( Plugins_Installer::get_plugins() );
1181        $plugin_slugs              = array_map(
1182            static function ( $slug ) {
1183                $parts = explode( '/', $slug );
1184                // Return the last segment of the filepath without the PHP extension
1185                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1186            },
1187            $plugin_slugs
1188        );
1189        $installed_jetpack_plugins = array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs );
1190        if ( is_countable( $installed_jetpack_plugins ) && count( $installed_jetpack_plugins ) >= 2 ) {
1191            return false;
1192        }
1193
1194        // Does the site have any purchases?
1195        $purchases = Wpcom_Products::get_site_current_purchases();
1196        if ( ! empty( $purchases ) && ! is_wp_error( $purchases ) ) {
1197            return false;
1198        }
1199
1200        return true;
1201    }
1202
1203    /**
1204     * Register My Jetpack's feature flags.
1205     *
1206     * @since $$next-version$$
1207     *
1208     * @return void
1209     */
1210    public static function register_feature_flags() {
1211        Feature_Flags::register(
1212            self::HEADER_CONTROLS_FLAG,
1213            array(
1214                'default'     => false,
1215                'description' => 'Show the global controls in the shared header of My Jetpack and Boost.',
1216                'owner'       => 'my-jetpack',
1217            )
1218        );
1219    }
1220
1221    /**
1222     *  Build flags for My Jetpack UI
1223     *
1224     *  @return array
1225     */
1226    public static function get_my_jetpack_flags() {
1227        $flags = array(
1228            'videoPressStats'          => Jetpack_Constants::is_true( 'JETPACK_MY_JETPACK_VIDEOPRESS_STATS_ENABLED' ),
1229            'showFullJetpackStatsCard' => class_exists( 'Jetpack' ) || Products\Stats::is_standalone_plugin_active(),
1230            // Only says which destination `manage_url` is: the legacy Stats page
1231            // caches its report and wants a `force_refresh` hint the dashboard does not.
1232            'premiumAnalyticsEnabled'  => Products\Stats::is_premium_analytics_enabled(),
1233            'showAiModuleToggle'       => Products\Jetpack_Ai::is_feature_ui_enabled(),
1234            'headerControls'           => Feature_Flags::is_enabled( self::HEADER_CONTROLS_FLAG ),
1235            'overviewV1'               => self::is_overview_v1_enabled(),
1236        );
1237
1238        return $flags;
1239    }
1240
1241    /**
1242     * Whether My Jetpack shows Overview v1.
1243     *
1244     * @since 6.10.0
1245     * @return bool
1246     */
1247    public static function is_overview_v1_enabled() {
1248        /**
1249         * Filters whether My Jetpack shows Overview v1.
1250         *
1251         * @since 6.10.0
1252         * @param bool $enabled Whether to show Overview v1. Default false.
1253         */
1254        return (bool) apply_filters( 'jetpack_my_jetpack_overview_v1', false );
1255    }
1256
1257    /**
1258     * Build the state the My Jetpack "try the new SEO experience" opt-in card hydrates from.
1259     *
1260     * The card invites an existing self-hosted install to switch over to the new Jetpack SEO
1261     * dashboard (JETPACK-1700). Gating lives server-side, where the signals actually are. The
1262     * card shows only when all of:
1263     *
1264     * - the new SEO product is available â€” the `rsm_jetpack_seo` feature filter is on (the SEO
1265     *   package autoloads regardless, so `class_exists()` alone isn't enough; the filter is the
1266     *   real availability switch and the same one the SEO package gates its own surface behind);
1267     * - the site is self-hosted â€” WordPress.com (Simple + Atomic) decides its own SEO surface, so
1268     *   the opt-in card is for self-hosted installs only;
1269     * - the install hasn't opted in yet â€” the SEO surface isn't visible. On wpcom
1270     *   the SEO package's `is_seo_surface_visible()` short-circuits to `true`, so the
1271     *   "not visible yet" check also doubles as the self-hosted guard, but we check the platform
1272     *   explicitly for clarity.
1273     *
1274     * Referenced through `class_exists()` rather than a hard composer dependency: both packages
1275     * ship inside the Jetpack plugin and the SEO surface is feature-flagged, so a guarded read of
1276     * its public API keeps this from adding plumbing to consumers that don't load SEO.
1277     *
1278     * The on-success destination is computed by the opt-in endpoint itself; we only seed the card
1279     * with the same admin URL so the button has a sensible fallback before the request resolves.
1280     *
1281     * @return array{showCard: bool, redirect: string}
1282     */
1283    public static function get_seo_opt_in_state() {
1284        // Guard with method_exists rather than class_exists: an older bundled SEO package can ship
1285        // the Initializer class without this method, and class_exists alone would still fatal.
1286        $seo_initializer = 'Automattic\Jetpack\SEO\Initializer';
1287        // @phan-suppress-next-line PhanUndeclaredClassReference -- optional SEO package, guarded by method_exists.
1288        $show_card = method_exists( $seo_initializer, 'is_optin_available' ) && $seo_initializer::is_optin_available();
1289
1290        return array(
1291            'showCard' => $show_card,
1292            'redirect' => admin_url( 'admin.php?page=jetpack-seo' ),
1293        );
1294    }
1295
1296    /**
1297     * Echoes the admin page content.
1298     *
1299     * @return void
1300     */
1301    public static function admin_page() {
1302        // Asked again here rather than inferred from the redirect: with the wizard on a
1303        // connected user is deliberately left on this screen to finish setup, and anyone
1304        // who cannot connect never reaches it.
1305        if ( self::is_onboarding_takeover() ) {
1306            echo '<div id="my-jetpack-container"></div>';
1307            return;
1308        }
1309
1310        if ( self::should_render_wp_build() ) {
1311            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.
1312        }
1313    }
1314
1315    /**
1316     * Register the REST API routes.
1317     *
1318     * @return void
1319     */
1320    public static function register_rest_endpoints() {
1321        if ( self::is_offline_features_enabled() ) {
1322            ( new REST_Main_Features() )->register_rest_routes();
1323            return;
1324        }
1325
1326        new REST_Products();
1327        new REST_Purchases();
1328        ( new REST_Jetpack_AI_JWT() )->register_rest_route();
1329        new REST_Recommendations_Evaluation();
1330
1331        ( new REST_Onboarding() )->register_rest_routes();
1332        ( new REST_Main_Features() )->register_rest_routes();
1333
1334        Products::register_product_endpoints();
1335        Historically_Active_Modules::register_rest_endpoints();
1336        Jetpack_Manage::register_rest_endpoints();
1337        Red_Bubble_Notifications::register_rest_endpoints();
1338
1339        register_rest_route(
1340            'my-jetpack/v1',
1341            'site',
1342            array(
1343                'methods'             => \WP_REST_Server::READABLE,
1344                'callback'            => __CLASS__ . '::get_site',
1345                'permission_callback' => __CLASS__ . '::permissions_callback',
1346            )
1347        );
1348
1349        register_rest_route(
1350            'my-jetpack/v1',
1351            'site/dismiss-welcome-banner',
1352            array(
1353                'methods'             => \WP_REST_Server::EDITABLE,
1354                'callback'            => __CLASS__ . '::dismiss_welcome_banner',
1355                'permission_callback' => __CLASS__ . '::permissions_callback',
1356            )
1357        );
1358    }
1359
1360    /**
1361     * Check user capability to access the endpoint.
1362     *
1363     * @access public
1364     * @static
1365     *
1366     * @return true|WP_Error
1367     */
1368    public static function permissions_callback() {
1369        return current_user_can( 'manage_options' );
1370    }
1371
1372    /**
1373     * Return true if we should initialize the My Jetpack admin page.
1374     */
1375    public static function should_initialize() {
1376        $should = true;
1377
1378        if ( ( new Status() )->is_offline_mode() ) {
1379            $should = self::is_offline_features_enabled();
1380        }
1381
1382        /**
1383         * Allows filtering whether My Jetpack should be initialized.
1384         * Outside REST requests, once the user is set, this cannot enable offline Features for a user without access.
1385         *
1386         * @since 0.5.0-alpha
1387         *
1388         * @param bool $shoud_initialize Should we initialize My Jetpack?
1389         */
1390        $should = apply_filters( 'jetpack_my_jetpack_should_initialize', $should );
1391
1392        // REST authenticates after route registration; its permission callbacks check access.
1393        if ( self::is_offline_features_enabled() && ! Jetpack_Constants::is_true( 'REST_REQUEST' ) && function_exists( 'wp_get_current_user' ) && did_action( 'set_current_user' ) && ! self::current_user_can_access_page() ) {
1394            return false;
1395        }
1396
1397        return $should;
1398    }
1399
1400    /**
1401     * Whether the site uses the local Features entry instead of the connected dashboard.
1402     *
1403     * @since 6.10.0
1404     * @return bool
1405     */
1406    public static function is_offline_features_enabled() {
1407        if ( ! ( new Status() )->is_offline_mode() ) {
1408            return false;
1409        }
1410
1411        /**
1412         * Enable local Features data and entry in offline mode.
1413         *
1414         * @since 6.10.0
1415         * @param bool $enabled Whether to enable offline Features. Default false.
1416         */
1417        return (bool) apply_filters( 'jetpack_my_jetpack_offline_features', false );
1418    }
1419
1420    /**
1421     * Hook into several connection-based actions to update the historically active Jetpack modules
1422     * If the transient that indicates the list needs to be synced, update it and delete the transient
1423     *
1424     * @return void
1425     */
1426    public static function setup_historically_active_jetpack_modules_sync() {
1427        // yummmm. ham.
1428        $ham = new Historically_Active_Modules();
1429        if ( get_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY ) && ! wp_doing_ajax() ) {
1430            $ham::update_historically_active_jetpack_modules();
1431            delete_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY );
1432        }
1433
1434        $actions = array(
1435            'jetpack_site_registered',
1436            'jetpack_user_authorized',
1437            'activated_plugin',
1438        );
1439
1440        foreach ( $actions as $action ) {
1441            add_action( $action, array( $ham, 'queue_historically_active_jetpack_modules_update' ), 5 );
1442        }
1443
1444        // Modules are often updated async, so we need to update them right away as there will sometimes be no page reload.
1445        add_action( 'jetpack_activate_module', array( $ham, 'update_historically_active_jetpack_modules' ), 5 );
1446    }
1447
1448    /**
1449     * Site full-data endpoint.
1450     *
1451     * @return object Site data.
1452     */
1453    public static function get_site() {
1454        $site_id           = \Jetpack_Options::get_option( 'id' );
1455        $wpcom_endpoint    = sprintf( '/sites/%d?force=wpcom', $site_id );
1456        $wpcom_api_version = '1.1';
1457        $response          = Client::wpcom_json_api_request_as_blog( $wpcom_endpoint, $wpcom_api_version );
1458        $response_code     = wp_remote_retrieve_response_code( $response );
1459        $body              = json_decode( wp_remote_retrieve_body( $response ) );
1460
1461        if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
1462            return new WP_Error( 'site_data_fetch_failed', 'Site data fetch failed', array( 'status' => $response_code ) );
1463        }
1464
1465        return rest_ensure_response( $body );
1466    }
1467
1468    /**
1469     * Populates the self::$site_info var with site data from the /sites/%d endpoint
1470     *
1471     * @return object|WP_Error
1472     */
1473    public static function get_site_info() {
1474        static $site_info = null;
1475
1476        if ( $site_info !== null ) {
1477            return $site_info;
1478        }
1479
1480        // Check for a cached value before doing lookup
1481        $stored_site_info = get_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY );
1482        if ( $stored_site_info !== false ) {
1483            return $stored_site_info;
1484        }
1485
1486        $response = self::get_site();
1487        if ( is_wp_error( $response ) ) {
1488            return $response;
1489        }
1490        $site_info = $response->data;
1491        set_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY, $site_info, DAY_IN_SECONDS );
1492
1493        return $site_info;
1494    }
1495
1496    /**
1497     * Returns whether a site has been determined "commercial" or not.
1498     *
1499     * @return bool|null
1500     */
1501    public static function is_commercial_site() {
1502        if ( is_wp_error( self::$site_info ) ) {
1503            return null;
1504        }
1505
1506        return empty( self::$site_info->options->is_commercial ) ? false : self::$site_info->options->is_commercial;
1507    }
1508
1509    /**
1510     * Check if site is registered (has been connected before).
1511     *
1512     * @return bool
1513     */
1514    public static function is_registered() {
1515        return (bool) \Jetpack_Options::get_option( 'id' );
1516    }
1517
1518    /**
1519     * Dismiss the welcome banner.
1520     *
1521     * @return \WP_REST_Response
1522     */
1523    public static function dismiss_welcome_banner() {
1524        \Jetpack_Options::update_option( 'dismissed_welcome_banner', true );
1525        return rest_ensure_response( array( 'success' => true ) );
1526    }
1527
1528    /**
1529     * Returns "yes" if the site has file write access to the plugins folder, "no" otherwise.
1530     *
1531     * @return string
1532     **/
1533    public static function has_file_system_write_access() {
1534
1535        $cache = get_transient( 'my_jetpack_write_access' );
1536
1537        if ( false !== $cache ) {
1538            return $cache;
1539        }
1540
1541        if ( ! function_exists( 'get_filesystem_method' ) ) {
1542            require_once ABSPATH . 'wp-admin/includes/file.php';
1543        }
1544
1545        require_once ABSPATH . 'wp-admin/includes/template.php';
1546
1547        $write_access = 'no';
1548
1549        $filesystem_method = get_filesystem_method( array(), WP_PLUGIN_DIR );
1550        if ( 'direct' === $filesystem_method ) {
1551            $write_access = 'yes';
1552        }
1553
1554        if ( 'no' === $write_access ) {
1555            ob_start();
1556            $filesystem_credentials_are_stored = request_filesystem_credentials( self_admin_url() );
1557            ob_end_clean();
1558
1559            if ( $filesystem_credentials_are_stored ) {
1560                $write_access = 'yes';
1561            }
1562        }
1563
1564        set_transient( 'my_jetpack_write_access', $write_access, 30 * MINUTE_IN_SECONDS );
1565
1566        return $write_access;
1567    }
1568
1569    /**
1570     * Get container IDC for the IDC screen.
1571     *
1572     * @return string
1573     */
1574    public static function get_idc_container_id() {
1575        return static::IDC_CONTAINER_ID;
1576    }
1577
1578    /**
1579     * Conditionally append the red bubble notification to the "Jetpack" menu item if there are alerts to show.
1580     *
1581     * On My Jetpack page: Uses blocking behavior to fetch fresh data.
1582     * On other admin pages: Uses cached data only to avoid blocking, with async JS fetch if cache is empty.
1583     *
1584     * @return void
1585     */
1586    public static function maybe_show_red_bubble() {
1587        global $pagenow;
1588
1589        // Don't show red bubble alerts for non-admin users
1590        // These alerts are generally only actionable for admins
1591        if ( ! current_user_can( 'manage_options' ) ) {
1592            return;
1593        }
1594
1595        // Don't show any red bubbles when Jetpack is disconnected
1596        // Users can't act on most alerts without a connection
1597        $connection = new Connection_Manager();
1598        if ( ! $connection->is_connected() ) {
1599            return;
1600        }
1601
1602        // Check if we're on the My Jetpack page.
1603        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1604        $page               = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
1605        $is_my_jetpack_page = $pagenow === 'admin.php' && $page === 'my-jetpack';
1606
1607        if ( $is_my_jetpack_page ) {
1608            // On My Jetpack page: use blocking behavior for fresh data.
1609            add_filter( 'my_jetpack_red_bubble_notification_slugs', array( Red_Bubble_Notifications::class, 'add_red_bubble_alerts' ) );
1610            $red_bubble_alerts = Red_Bubble_Notifications::get_red_bubble_alerts();
1611        } else {
1612            // On other pages: use cached data only to avoid blocking.
1613            $cached_alerts = Red_Bubble_Notifications::get_cached_alerts();
1614
1615            if ( false === $cached_alerts ) {
1616                // No cache: warm it asynchronously via JS. Register a hidden zero-count
1617                // placeholder so Menu_Renderer emits a `my-jetpack` badge element the
1618                // warmer can reveal (see async-notification-bubble.ts) without a reload.
1619                Menu_Badges::init(); // idempotent; wires the renderer.
1620                Notification_Counts::register(
1621                    'my-jetpack',
1622                    array(
1623                        'menu_slug' => 'my-jetpack',
1624                        'count'     => 0,
1625                        'type'      => 'count',
1626                    )
1627                );
1628                add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_red_bubble_script' ) );
1629                return;
1630            }
1631
1632            $red_bubble_alerts = $cached_alerts;
1633        }
1634
1635        // Filter out silent alerts.
1636        $red_bubble_alerts = array_filter(
1637            $red_bubble_alerts,
1638            function ( $alert ) {
1639                return empty( $alert['is_silent'] );
1640            }
1641        );
1642
1643        // Report each non-silent alert to the central menu-badges registry as an
1644        // attention entry (count 1). The registry + renderer own the badge.
1645        Menu_Badges::init(); // idempotent; wires the renderer.
1646
1647        // Connection errors are owned by the Error Handler (the single source of truth):
1648        // surface any it reports for this viewer as their own attention entry. Read live
1649        // rather than from the red-bubble transient, since that cache is not viewer-keyed.
1650        $has_connection_error = self::has_connection_error();
1651        if ( $has_connection_error ) {
1652            Notification_Counts::register(
1653                'my-jetpack-connection-error',
1654                array(
1655                    'menu_slug' => 'my-jetpack',
1656                    'type'      => 'attention',
1657                )
1658            );
1659        }
1660
1661        $counted_elsewhere = self::get_alerts_counted_elsewhere();
1662        foreach ( array_keys( $red_bubble_alerts ) as $slug ) {
1663            if ( in_array( $slug, $counted_elsewhere, true ) ) {
1664                continue;
1665            }
1666            // The missing-connection slug and a connection error describe the same broken
1667            // connection; count it once (the error, above, is the more specific signal).
1668            if ( $has_connection_error && 'missing-connection' === $slug ) {
1669                continue;
1670            }
1671            Notification_Counts::register(
1672                'my-jetpack-' . $slug,
1673                array(
1674                    'menu_slug' => 'my-jetpack',
1675                    'type'      => 'attention',
1676                )
1677            );
1678        }
1679    }
1680
1681    /**
1682     * Whether the Connection Error Handler reports a displayable connection error
1683     * for the current viewer.
1684     *
1685     * Read live (not via the red-bubble transient): get_displayable_errors() is a
1686     * cached option read that the Error Handler already scopes and caches per viewer.
1687     * Guarded for the mid-plugin-update window, where a stale connection package
1688     * predating the method can be loaded.
1689     *
1690     * @return bool
1691     */
1692    private static function has_connection_error() {
1693        if ( ! class_exists( Connection_Error_Handler::class ) || ! method_exists( Connection_Error_Handler::class, 'get_displayable_errors' ) ) {
1694            return false;
1695        }
1696        return ! empty( Connection_Error_Handler::get_instance()->get_displayable_errors() );
1697    }
1698
1699    /**
1700     * Alerts left out of the My Jetpack badge because another menu item already counts them.
1701     *
1702     * The Protect page reports its own count, from the standalone plugin (class-jetpack-protect.php::admin_page_init())
1703     * or the Protect package's dashboard; without either, nobody else counts the threats, so they must stay.
1704     *
1705     * @return string[] Alert slugs.
1706     */
1707    private static function get_alerts_counted_elsewhere() {
1708        return Products\Protect::has_protect_page() ? array( 'protect_has_threats' ) : array();
1709    }
1710
1711    /**
1712     * Enqueue the notification bubble script.
1713     * Fetches fresh alert data via REST API without blocking page load.
1714     *
1715     * @return void
1716     */
1717    public static function enqueue_red_bubble_script() {
1718        Assets::register_script(
1719            'my-jetpack-notification-bubble',
1720            '../build/async-notification-bubble.js',
1721            __FILE__,
1722            array(
1723                'enqueue'   => true,
1724                'in_footer' => true,
1725            )
1726        );
1727        wp_localize_script(
1728            'my-jetpack-notification-bubble',
1729            'myJetpackNotificationBubble',
1730            array( 'excludedAlerts' => self::get_alerts_counted_elsewhere() )
1731        );
1732    }
1733
1734    /**
1735     * Get list of module names sorted by their recommendation score
1736     *
1737     * @return array|null
1738     */
1739    public static function get_recommended_modules() {
1740        $recommendations_evaluation = \Jetpack_Options::get_option( 'recommendations_evaluation', null );
1741
1742        if ( empty( $recommendations_evaluation ) || ! is_array( $recommendations_evaluation ) ) {
1743            return null;
1744        }
1745
1746        arsort( $recommendations_evaluation ); // Sort by scores in descending order
1747
1748        return array_keys( $recommendations_evaluation ); // Get only module names
1749    }
1750}