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