Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.44% covered (warning)
86.44%
650 / 752
72.46% covered (warning)
72.46%
50 / 69
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
86.44% covered (warning)
86.44%
650 / 752
72.46% covered (warning)
72.46%
50 / 69
454.27
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.90% covered (success)
96.90%
125 / 129
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                'lifecycleStats'         => array(
984                    'jetpackPlugins'            => self::get_installed_jetpack_plugins(),
985                    'historicallyActiveModules' => \Jetpack_Options::get_option( 'historically_active_modules', array() ),
986                    'brokenModules'             => Red_Bubble_Notifications::check_for_broken_modules(),
987                    'isSiteConnected'           => $connection->is_connected(),
988                    'isUserConnected'           => $connection->is_user_connected(),
989                    'modules'                   => self::get_active_modules(),
990                ),
991                'recommendedModules'     => array(
992                    'modules'    => self::get_recommended_modules(),
993                    'isFirstRun' => \Jetpack_Options::get_option( 'recommendations_first_run', true ),
994                    'dismissed'  => \Jetpack_Options::get_option( 'dismissed_recommendations', false ),
995                ),
996                'isStatsModuleActive'    => $modules->is_active( 'stats' ),
997                'canUserViewStats'       => current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ),
998                'hiddenFeatures'         => Feature_Visibility::get_hidden(),
999                'deprecatedModules'      => Feature_Visibility::get_deprecated_modules(),
1000                'sandboxedDomain'        => $sandboxed_domain,
1001                'isDevVersion'           => $is_dev_version,
1002                'isAtomic'               => ( new Status_Host() )->is_woa_site(),
1003                'isJetpackPluginActive'  => class_exists( 'Jetpack' ),
1004                'latestBoostSpeedScores' => $latest_score,
1005                'seoOptIn'               => self::get_seo_opt_in_state(),
1006                'protectPageModuleUrls'  => (object) Products\Protect::get_protect_page_module_urls(),
1007                'partnerCoupon'          => self::get_partner_coupon_screen(),
1008            )
1009        );
1010
1011        wp_localize_script(
1012            $data_handle,
1013            'myJetpackRest',
1014            array(
1015                'apiRoot'  => esc_url_raw( rest_url() ),
1016                'apiNonce' => wp_create_nonce( 'wp_rest' ),
1017            )
1018        );
1019
1020        // Connection Initial State.
1021        Connection_Initial_State::render_script( $data_handle );
1022
1023        // Required for Analytics.
1024        if ( self::can_use_analytics() ) {
1025            Tracking::register_tracks_functions_scripts( true );
1026        }
1027    }
1028
1029    /**
1030     * What the shared header needs beyond the rest of the state, so it renders without a request.
1031     *
1032     * @since 6.10.0
1033     *
1034     * @return array{connectorsUrl: string|null} The state.
1035     */
1036    public static function get_header_state() {
1037        return array(
1038            // The same probe the connection package uses: the core screen ships with WordPress 7.0.
1039            'connectorsUrl' => file_exists( ABSPATH . 'wp-admin/options-connectors.php' ) ? admin_url( 'options-connectors.php' ) : null,
1040        );
1041    }
1042
1043    /**
1044     * Add My Jetpack data to the unified script data object.
1045     *
1046     * @param array $data The script data.
1047     * @return array
1048     */
1049    public static function add_script_data( $data ) {
1050        $block_availability = ! self::is_offline_features_enabled() && class_exists( '\Jetpack_Gutenberg' )
1051            ? \Jetpack_Gutenberg::get_cached_availability()
1052            : array();
1053
1054        $data['myJetpack']['siteEditor'] = array(
1055            'isBlockTheme'            => function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(),
1056            'isSharingBlockAvailable' => isset( $block_availability['sharing-buttons'] )
1057                && $block_availability['sharing-buttons']['available'],
1058            'isLikeBlockAvailable'    => isset( $block_availability['like'] )
1059                && $block_availability['like']['available'],
1060            'activeThemeStylesheet'   => get_stylesheet(),
1061        );
1062
1063        return $data;
1064    }
1065
1066    /**
1067     * Whether module management can use the REST API.
1068     *
1069     * @since 6.11.0
1070     * @return bool
1071     */
1072    public static function is_modules_rest_api_enabled() {
1073        /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
1074        $enabled = apply_filters( 'rest_enabled', true );
1075        $user    = wp_get_current_user();
1076        $auth    = false;
1077        // Passing true skips WordPress's nonce check, which would sign the user out; restore the user if another filter clears it.
1078        try {
1079            /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
1080            $auth = apply_filters( 'rest_authentication_errors', true );
1081        } finally {
1082            wp_set_current_user( $user->ID );
1083        }
1084        return $enabled && ! is_wp_error( $enabled ) && false !== $auth && ! is_wp_error( $auth );
1085    }
1086
1087    /**
1088     * Get a Features management link with a classic Modules fallback.
1089     *
1090     * @since 6.11.0
1091     * @param array     $args     Query arguments from a classic Modules screen link; only `s` and `module_tag` can carry over to Features.
1092     * @param bool|null $eligible Set to true when the URL points at the Features tab, false when it is the classic Modules screen.
1093     * @return string The management URL.
1094     */
1095    public static function get_modules_management_url( $args = array(), &$eligible = null ) {
1096        $eligible = false;
1097        /**
1098         * Filters whether links to the classic Modules screen may send people to the Features tab in My Jetpack instead.
1099         *
1100         * @since 6.11.0
1101         * @param bool $available True to allow the Features tab; false keeps the classic Modules screen, for a host where My Jetpack is not shown.
1102         */
1103        $available = apply_filters( 'jetpack_my_jetpack_modules_management_available', true );
1104        $available = $available && self::is_modules_to_features_enabled();
1105        $classic   = add_query_arg( array_merge( urlencode_deep( $args ), array( 'page' => 'jetpack_modules' ) ), admin_url( 'admin.php' ) );
1106        if (
1107            ! $available
1108            || array_diff( array_keys( $args ), array( 's', 'module_tag' ) )
1109            || array_filter( $args, 'is_string' ) !== $args
1110            // Keep links on Modules unless Features can show controls for this installation.
1111            || Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG )
1112            || ! current_user_can( 'jetpack_manage_modules' )
1113            || ! current_user_can( 'manage_options' )
1114            || ! self::current_user_can_access_page()
1115            || ( new Status() )->is_offline_mode()
1116            || ( new Status_Host() )->is_wpcom_simple()
1117            || is_network_admin()
1118            || ! self::should_initialize()
1119            || ! ( new Connection_Manager() )->is_connected()
1120            || null !== self::get_partner_coupon_screen()
1121            // Predict My Jetpack's setup redirect for a Features URL with no step; keep both decisions in sync.
1122            || null !== self::get_onboarding_redirect_args_for_step( '' )
1123            || ! self::is_modules_rest_api_enabled()
1124        ) {
1125            return $classic;
1126        }
1127
1128        if ( isset( $args['module_tag'] ) && '' !== $args['module_tag'] ) {
1129            if ( ! function_exists( 'jetpack_get_module_i18n_tag' ) ) {
1130                return $classic;
1131            }
1132            $known      = array();
1133            $modules    = new Modules();
1134            $hidden     = Main_Features::get_hidden_modules();
1135            $deprecated = Feature_Visibility::get_deprecated_modules();
1136            foreach ( $modules->get_available() as $slug ) {
1137                if ( in_array( $slug, $hidden, true ) || ( in_array( $slug, $deprecated, true ) && ! $modules->is_active( $slug ) ) ) {
1138                    continue;
1139                }
1140                $module = $modules->get( $slug );
1141                $known  = array_merge( $known, $module['module_tags'] ?? array() );
1142            }
1143            $known = array_map( array( Jetpack::class, 'translate_module_tag' ), $known );
1144            if ( ! in_array( $args['module_tag'], $known, true ) ) {
1145                return $classic;
1146            }
1147        }
1148
1149        if ( isset( $args['s'] ) && '' !== $args['s'] ) {
1150            require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
1151            $hidden        = Main_Features::get_hidden_modules();
1152            $deprecated    = Feature_Visibility::get_deprecated_modules();
1153            $hidden_match  = false;
1154            $visible_match = false;
1155            foreach ( Jetpack::get_translated_modules( \Jetpack_Admin::init()->get_modules() ) as $slug => $module ) {
1156                if ( ! empty( $args['module_tag'] ) && ! in_array( $args['module_tag'], $module['module_tags'], true ) ) {
1157                    continue;
1158                }
1159                $text  = $module['name'] . ' ' . $module['description'] . ' ' . $module['long_description'] . ' ' . $module['search_terms'] . ' ' . implode( ',', $module['module_tags'] );
1160                $match = function_exists( 'mb_stripos' ) ? mb_stripos( $text, $args['s'] ) : stripos( $text, $args['s'] );
1161                if ( false === $match ) {
1162                    continue;
1163                }
1164                if ( in_array( $slug, $hidden, true ) || ( in_array( $slug, $deprecated, true ) && ! $module['activated'] ) ) {
1165                    $hidden_match = true;
1166                } else {
1167                    $visible_match = true;
1168                    break;
1169                }
1170            }
1171            if ( $hidden_match && ! $visible_match ) {
1172                return $classic;
1173            }
1174        }
1175
1176        $eligible = true;
1177        $hash     = array();
1178        if ( isset( $args['s'] ) && '' !== $args['s'] ) {
1179            $hash['search'] = $args['s'];
1180        }
1181        if ( isset( $args['module_tag'] ) && '' !== $args['module_tag'] ) {
1182            $hash['module_tag'] = $args['module_tag'];
1183        }
1184        return add_query_arg(
1185            array_merge(
1186                urlencode_deep( $args ),
1187                array(
1188                    'page'             => 'my-jetpack',
1189                    'modules_fallback' => '1',
1190                )
1191            ),
1192            admin_url( 'admin.php' )
1193        )
1194            . '#/features' . ( $hash ? '?' . http_build_query( $hash, '', '&', PHP_QUERY_RFC3986 ) : '' );
1195    }
1196
1197    /**
1198     * With `my-jetpack-modules-to-features` on, send My Jetpack requests with `modules_fallback=1` back to classic Modules:
1199     * always without JavaScript, and for everyone when REST is unavailable.
1200     *
1201     * @since 6.11.0
1202     * @return void
1203     */
1204    public static function print_modules_fallback() {
1205        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only navigation fallback.
1206        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Only the exact allowlisted marker is accepted.
1207        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' ) ) {
1208            return;
1209        }
1210        $args = array( 'page' => 'jetpack_modules' );
1211        foreach ( array( 's', 'module_tag' ) as $key ) {
1212            if ( isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ) {
1213                $args[ $key ] = sanitize_text_field( wp_unslash( $_GET[ $key ] ) );
1214            }
1215        }
1216        // phpcs:enable WordPress.Security.NonceVerification.Recommended
1217        $meta = '<meta http-equiv="refresh" content="0; url=' . esc_url( add_query_arg( urlencode_deep( $args ), admin_url( 'admin.php' ) ) ) . '">';
1218        echo '<noscript>' . $meta . '</noscript>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- URL escaped above.
1219        if ( ! self::is_modules_rest_api_enabled() ) {
1220            echo $meta; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- URL escaped above.
1221        }
1222    }
1223
1224    /**
1225     * Get the slug and label of the Features tab, for footer links to it.
1226     *
1227     * Kept for footers built before the Products tab was removed: Jetpack 16.3 calls this, and
1228     * older JS footers read it from script data. Without it they link to "Products".
1229     *
1230     * @since 6.4.0
1231     *
1232     * @return array{slug: string, label: string}
1233     */
1234    public static function get_products_section() {
1235        return array(
1236            'slug'  => 'features',
1237            'label' => _x( 'Features', 'Navigation item', 'jetpack-my-jetpack' ),
1238        );
1239    }
1240
1241    /**
1242     * Add My Jetpack's availability, image base URL and link targets to admin script data.
1243     *
1244     * Printed on every admin page by Script_Data, so the connection screen can resolve its
1245     * illustrations and Jetpack footers know whether to link to My Jetpack.
1246     *
1247     * @since 6.3.0
1248     *
1249     * @param array $data Script data.
1250     * @return array
1251     */
1252    public static function add_admin_script_data( $data ) {
1253        $data['myJetpack']['isAvailable']     = self::is_admin_page_available();
1254        $data['myJetpack']['assetsUrl']       = self::get_assets_url();
1255        $data['myJetpack']['productsSection'] = self::get_products_section();
1256        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only selects which navigation data to seed.
1257        if ( in_array( $_GET['page'] ?? '', array( 'my-jetpack', 'jetpack-settings', 'jetpack' ), true ) ) {
1258            $data['myJetpack']['modulesManagementUrl'] = self::get_modules_management_url();
1259        }
1260
1261        // Only administrators can open the pages this points at, so spare everyone else the lookup.
1262        if ( current_user_can( 'manage_options' ) ) {
1263            $data['myJetpack']['aiManageUrl'] = esc_url_raw( Products\Jetpack_Ai::get_manage_url() );
1264        }
1265
1266        if (
1267            self::is_my_jetpack_admin_request()
1268            && self::is_offline_features_enabled()
1269            && self::should_initialize()
1270            && REST_Main_Features::permissions_callback()
1271        ) {
1272            $data['myJetpack']['offlineFeatures'] = array(
1273                'mainFeatures' => Main_Features::get_state( true ),
1274                'plugins'      => Plugins_Installer::get_plugins(),
1275            );
1276            $data['myJetpack']['offlineFeatures']['mainFeatures']['available_admin_pages'] = self::get_registered_admin_page_slugs();
1277        }
1278
1279        return $data;
1280    }
1281
1282    /**
1283     * Get the slugs of registered admin pages.
1284     *
1285     * Skip hooks containing `?`: they are `edit.php?post_type=...` style pages, not page slugs.
1286     *
1287     * @return string[]
1288     */
1289    private static function get_registered_admin_page_slugs() {
1290        global $_registered_pages;
1291
1292        $hooks = array_filter(
1293            array_keys( $_registered_pages ?? array() ),
1294            static fn( $hook ) => false !== strpos( $hook, '_page_' ) && false === strpos( $hook, '?' )
1295        );
1296
1297        return array_values(
1298            array_unique(
1299                array_map( static fn( $hook ) => substr( $hook, strpos( $hook, '_page_' ) + 6 ), $hooks )
1300            )
1301        );
1302    }
1303
1304    /**
1305     * Whether My Jetpack's admin page is available to the current user.
1306     *
1307     * Meaningful only after admin_menu has registered the page.
1308     *
1309     * @since 6.5.0
1310     *
1311     * @return bool
1312     */
1313    public static function is_admin_page_available() {
1314        global $_registered_pages;
1315
1316        return did_action( 'my_jetpack_init' ) > 0
1317            && isset( $_registered_pages[ get_plugin_page_hookname( 'my-jetpack', 'jetpack' ) ] )
1318            && current_user_can( 'edit_posts' );
1319    }
1320
1321    /**
1322     * Get the base URL of the package's built images, with a trailing slash.
1323     *
1324     * @since 6.3.0
1325     *
1326     * @return string
1327     */
1328    public static function get_assets_url() {
1329        return trailingslashit( Assets::normalize_path( plugins_url( '../build/images/', __FILE__ ) ) );
1330    }
1331
1332    /**
1333     * Get installed Jetpack plugins
1334     *
1335     * @return array
1336     */
1337    public static function get_installed_jetpack_plugins() {
1338        $plugin_slugs = array_keys( Plugins_Installer::get_plugins() );
1339        $plugin_slugs = array_map(
1340            static function ( $slug ) {
1341                $parts = explode( '/', $slug );
1342                // Return the last segment of the filepath without the PHP extension
1343                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1344            },
1345            $plugin_slugs
1346        );
1347
1348        return array_values( array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs ) );
1349    }
1350
1351    /**
1352     * Get active modules (except ones enabled by default)
1353     *
1354     * @return array
1355     */
1356    public static function get_active_modules() {
1357        $modules        = new Modules();
1358        $active_modules = $modules->get_active();
1359
1360        // if the Jetpack plugin is active, filter out the modules that are active by default
1361        if ( class_exists( 'Jetpack' ) && ! empty( $active_modules ) ) {
1362            $active_modules = array_diff( $active_modules, Jetpack::get_default_modules() );
1363        }
1364        return array_values( $active_modules );
1365    }
1366
1367    /**
1368     * Determine if the current user is "new" to Jetpack
1369     * This is used to vary some messaging in My Jetpack
1370     *
1371     * On the front-end, purchases are also taken into account
1372     *
1373     * @return bool
1374     */
1375    public static function is_jetpack_user_new() {
1376        // is the user connected?
1377        $connection = new Connection_Manager();
1378        if ( $connection->is_user_connected() ) {
1379            return false;
1380        }
1381
1382        // TODO: add a data point for the last known connection/ disconnection time
1383
1384        // are any modules active?
1385        $active_modules = self::get_active_modules();
1386        if ( ! empty( $active_modules ) ) {
1387            return false;
1388        }
1389
1390        // check for other Jetpack plugins that are installed on the site (active or not)
1391        // If there's more than one Jetpack plugin active, this user is not "new"
1392        $plugin_slugs              = array_keys( Plugins_Installer::get_plugins() );
1393        $plugin_slugs              = array_map(
1394            static function ( $slug ) {
1395                $parts = explode( '/', $slug );
1396                // Return the last segment of the filepath without the PHP extension
1397                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1398            },
1399            $plugin_slugs
1400        );
1401        $installed_jetpack_plugins = array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs );
1402        if ( is_countable( $installed_jetpack_plugins ) && count( $installed_jetpack_plugins ) >= 2 ) {
1403            return false;
1404        }
1405
1406        // Does the site have any purchases?
1407        $purchases = Wpcom_Products::get_site_current_purchases();
1408        if ( ! empty( $purchases ) && ! is_wp_error( $purchases ) ) {
1409            return false;
1410        }
1411
1412        return true;
1413    }
1414
1415    /**
1416     * Register My Jetpack's feature flags.
1417     *
1418     * @since 6.11.0
1419     *
1420     * @return void
1421     */
1422    public static function register_feature_flags() {
1423        Feature_Flags::register(
1424            self::ACCOUNT_CONNECT_FLAG,
1425            array(
1426                'default'     => false,
1427                '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.',
1428                'owner'       => 'my-jetpack',
1429            )
1430        );
1431        Feature_Flags::register(
1432            self::HEADER_CONTROLS_FLAG,
1433            array(
1434                'default'     => false,
1435                'description' => 'Show the global controls in the shared header of My Jetpack and Boost.',
1436                'owner'       => 'my-jetpack',
1437            )
1438        );
1439        Feature_Flags::register(
1440            self::OVERVIEW_V1_FLAG,
1441            array(
1442                'default'     => false,
1443                'description' => 'Show Overview v1 in My Jetpack.',
1444                'owner'       => 'my-jetpack',
1445            )
1446        );
1447        Feature_Flags::register(
1448            self::MODULES_TO_FEATURES_FLAG,
1449            array(
1450                'default'     => false,
1451                'description' => 'Open supported classic Modules links in Features.',
1452                'owner'       => 'my-jetpack',
1453            )
1454        );
1455    }
1456
1457    /**
1458     * Whether the shared header shows its controls: the flag is on, My Jetpack runs, and not in offline Features.
1459     *
1460     * Boost's dashboard gets My Jetpack's page state even where My Jetpack is off, hence the second check.
1461     * Offline Features prints no connection state for the controls to read, and has no connection to show.
1462     *
1463     * @return bool
1464     */
1465    private static function is_header_controls_enabled() {
1466        return Feature_Flags::is_enabled( self::HEADER_CONTROLS_FLAG )
1467            && self::should_initialize()
1468            && ! self::is_offline_features_enabled();
1469    }
1470
1471    /**
1472     * Give dashboards outside My Jetpack, such as Boost's, the header's controls as a shared script.
1473     *
1474     * Bundling them instead would ship a second copy of the connection package next to the page's own.
1475     *
1476     * @return void
1477     */
1478    private static function maybe_enqueue_header_controls_script() {
1479        if ( self::is_my_jetpack_admin_request() || ! self::is_header_controls_enabled() ) {
1480            return;
1481        }
1482
1483        Assets::register_script(
1484            self::HEADER_CONTROLS_SCRIPT_HANDLE,
1485            '../build/header-controls.js',
1486            __FILE__,
1487            array(
1488                'dependencies' => array( self::DATA_SCRIPT_HANDLE ),
1489                'enqueue'      => true,
1490                'in_footer'    => true,
1491                'textdomain'   => 'jetpack-my-jetpack',
1492            )
1493        );
1494    }
1495
1496    /**
1497     *  Build flags for My Jetpack UI
1498     *
1499     *  @return array
1500     */
1501    public static function get_my_jetpack_flags() {
1502        $flags = array(
1503            'videoPressStats'          => Jetpack_Constants::is_true( 'JETPACK_MY_JETPACK_VIDEOPRESS_STATS_ENABLED' ),
1504            'showFullJetpackStatsCard' => class_exists( 'Jetpack' ) || Products\Stats::is_standalone_plugin_active(),
1505            // Only says which destination `manage_url` is: the legacy Stats page
1506            // caches its report and wants a `force_refresh` hint the dashboard does not.
1507            'premiumAnalyticsEnabled'  => Products\Stats::is_premium_analytics_enabled(),
1508            'showAiModuleToggle'       => Products\Jetpack_Ai::is_feature_ui_enabled(),
1509            'headerControls'           => self::is_header_controls_enabled(),
1510            'accountConnect'           => Feature_Flags::is_enabled( self::ACCOUNT_CONNECT_FLAG ),
1511            'overviewV1'               => self::is_overview_v1_enabled(),
1512        );
1513
1514        if ( $flags['accountConnect'] ) {
1515            $flags['showAiModuleToggleOnceConnected'] = Products\Jetpack_Ai::is_feature_ui_waiting_for_owner();
1516        }
1517
1518        return $flags;
1519    }
1520
1521    /**
1522     * Whether My Jetpack shows Overview v1.
1523     *
1524     * @since 6.10.0
1525     * @return bool
1526     */
1527    public static function is_overview_v1_enabled() {
1528        return Feature_Flags::is_enabled( self::OVERVIEW_V1_FLAG );
1529    }
1530
1531    /**
1532     * Whether Modules links may use Features.
1533     *
1534     * @return bool
1535     */
1536    private static function is_modules_to_features_enabled() {
1537        return Feature_Flags::is_enabled( self::MODULES_TO_FEATURES_FLAG );
1538    }
1539
1540    /**
1541     * Build the state the My Jetpack "try the new SEO experience" opt-in card hydrates from.
1542     *
1543     * The card invites an existing self-hosted install to switch over to the new Jetpack SEO
1544     * dashboard (JETPACK-1700). Gating lives server-side, where the signals actually are. The
1545     * card shows only when all of:
1546     *
1547     * - the new SEO product is available â€” the `rsm_jetpack_seo` feature filter is on (the SEO
1548     *   package autoloads regardless, so `class_exists()` alone isn't enough; the filter is the
1549     *   real availability switch and the same one the SEO package gates its own surface behind);
1550     * - the site is self-hosted â€” WordPress.com (Simple + Atomic) decides its own SEO surface, so
1551     *   the opt-in card is for self-hosted installs only;
1552     * - the install hasn't opted in yet â€” the SEO surface isn't visible. On wpcom
1553     *   the SEO package's `is_seo_surface_visible()` short-circuits to `true`, so the
1554     *   "not visible yet" check also doubles as the self-hosted guard, but we check the platform
1555     *   explicitly for clarity.
1556     *
1557     * Referenced through `class_exists()` rather than a hard composer dependency: both packages
1558     * ship inside the Jetpack plugin and the SEO surface is feature-flagged, so a guarded read of
1559     * its public API keeps this from adding plumbing to consumers that don't load SEO.
1560     *
1561     * The on-success destination is computed by the opt-in endpoint itself; we only seed the card
1562     * with the same admin URL so the button has a sensible fallback before the request resolves.
1563     *
1564     * @return array{showCard: bool, redirect: string}
1565     */
1566    public static function get_seo_opt_in_state() {
1567        // Guard with method_exists rather than class_exists: an older bundled SEO package can ship
1568        // the Initializer class without this method, and class_exists alone would still fatal.
1569        $seo_initializer = 'Automattic\Jetpack\SEO\Initializer';
1570        // @phan-suppress-next-line PhanUndeclaredClassReference -- optional SEO package, guarded by method_exists.
1571        $show_card = method_exists( $seo_initializer, 'is_optin_available' ) && $seo_initializer::is_optin_available();
1572
1573        return array(
1574            'showCard' => $show_card,
1575            'redirect' => admin_url( 'admin.php?page=jetpack-seo' ),
1576        );
1577    }
1578
1579    /**
1580     * Echoes the admin page content.
1581     *
1582     * @return void
1583     */
1584    public static function admin_page() {
1585        // Asked again here rather than inferred from the redirect: with the wizard on a
1586        // connected user is deliberately left on this screen to finish setup, and anyone
1587        // who cannot connect never reaches it.
1588        if ( self::is_onboarding_takeover() ) {
1589            echo '<div id="my-jetpack-container"></div>';
1590            return;
1591        }
1592
1593        if ( self::should_render_wp_build() ) {
1594            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.
1595        }
1596    }
1597
1598    /**
1599     * Register the REST API routes.
1600     *
1601     * @return void
1602     */
1603    public static function register_rest_endpoints() {
1604        if ( self::is_offline_features_enabled() ) {
1605            ( new REST_Main_Features() )->register_rest_routes();
1606            return;
1607        }
1608
1609        new REST_Products();
1610        new REST_Purchases();
1611        ( new REST_Jetpack_AI_JWT() )->register_rest_route();
1612        new REST_Recommendations_Evaluation();
1613
1614        ( new REST_Onboarding() )->register_rest_routes();
1615        ( new REST_Main_Features() )->register_rest_routes();
1616
1617        Products::register_product_endpoints();
1618        Historically_Active_Modules::register_rest_endpoints();
1619        Jetpack_Manage::register_rest_endpoints();
1620        Red_Bubble_Notifications::register_rest_endpoints();
1621
1622        if ( self::is_overview_v1_enabled() ) {
1623            REST_Blog_Posts::register_rest_endpoints();
1624        }
1625
1626        register_rest_route(
1627            'my-jetpack/v1',
1628            'site',
1629            array(
1630                'methods'             => \WP_REST_Server::READABLE,
1631                'callback'            => __CLASS__ . '::get_site',
1632                'permission_callback' => __CLASS__ . '::permissions_callback',
1633            )
1634        );
1635
1636        register_rest_route(
1637            'my-jetpack/v1',
1638            'site/dismiss-welcome-banner',
1639            array(
1640                'methods'             => \WP_REST_Server::EDITABLE,
1641                'callback'            => __CLASS__ . '::dismiss_welcome_banner',
1642                'permission_callback' => __CLASS__ . '::permissions_callback',
1643            )
1644        );
1645    }
1646
1647    /**
1648     * Check user capability to access the endpoint.
1649     *
1650     * @access public
1651     * @static
1652     *
1653     * @return true|WP_Error
1654     */
1655    public static function permissions_callback() {
1656        return current_user_can( 'manage_options' );
1657    }
1658
1659    /**
1660     * Return true if we should initialize the My Jetpack admin page.
1661     */
1662    public static function should_initialize() {
1663        $should = true;
1664
1665        if ( ( new Status() )->is_offline_mode() ) {
1666            $should = self::is_offline_features_enabled();
1667        }
1668
1669        /**
1670         * Allows filtering whether My Jetpack should be initialized.
1671         * Outside REST requests, once the user is set, this cannot enable offline Features for a user without access.
1672         *
1673         * @since 0.5.0-alpha
1674         *
1675         * @param bool $shoud_initialize Should we initialize My Jetpack?
1676         */
1677        $should = apply_filters( 'jetpack_my_jetpack_should_initialize', $should );
1678
1679        // REST authenticates after route registration; its permission callbacks check access.
1680        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() ) {
1681            return false;
1682        }
1683
1684        return $should;
1685    }
1686
1687    /**
1688     * Whether the site uses the local Features entry instead of the connected dashboard.
1689     *
1690     * @since 6.10.0
1691     * @return bool
1692     */
1693    public static function is_offline_features_enabled() {
1694        if ( ! ( new Status() )->is_offline_mode() ) {
1695            return false;
1696        }
1697
1698        /**
1699         * Enable local Features data and entry in offline mode.
1700         *
1701         * @since 6.10.0
1702         * @param bool $enabled Whether to enable offline Features. Default false.
1703         */
1704        return (bool) apply_filters( 'jetpack_my_jetpack_offline_features', false );
1705    }
1706
1707    /**
1708     * Hook into several connection-based actions to update the historically active Jetpack modules
1709     * If the transient that indicates the list needs to be synced, update it and delete the transient
1710     *
1711     * @return void
1712     */
1713    public static function setup_historically_active_jetpack_modules_sync() {
1714        // yummmm. ham.
1715        $ham = new Historically_Active_Modules();
1716        if ( get_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY ) && ! wp_doing_ajax() ) {
1717            $ham::update_historically_active_jetpack_modules();
1718            delete_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY );
1719        }
1720
1721        $actions = array(
1722            'jetpack_site_registered',
1723            'jetpack_user_authorized',
1724            'activated_plugin',
1725        );
1726
1727        foreach ( $actions as $action ) {
1728            add_action( $action, array( $ham, 'queue_historically_active_jetpack_modules_update' ), 5 );
1729        }
1730
1731        // Modules are often updated async, so we need to update them right away as there will sometimes be no page reload.
1732        add_action( 'jetpack_activate_module', array( $ham, 'update_historically_active_jetpack_modules' ), 5 );
1733    }
1734
1735    /**
1736     * Site full-data endpoint.
1737     *
1738     * @return object Site data.
1739     */
1740    public static function get_site() {
1741        $site_id           = \Jetpack_Options::get_option( 'id' );
1742        $wpcom_endpoint    = sprintf( '/sites/%d?force=wpcom', $site_id );
1743        $wpcom_api_version = '1.1';
1744        $response          = Client::wpcom_json_api_request_as_blog( $wpcom_endpoint, $wpcom_api_version );
1745        $response_code     = wp_remote_retrieve_response_code( $response );
1746        $body              = json_decode( wp_remote_retrieve_body( $response ) );
1747
1748        if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
1749            return new WP_Error( 'site_data_fetch_failed', 'Site data fetch failed', array( 'status' => $response_code ) );
1750        }
1751
1752        return rest_ensure_response( $body );
1753    }
1754
1755    /**
1756     * Populates the self::$site_info var with site data from the /sites/%d endpoint
1757     *
1758     * @return object|WP_Error
1759     */
1760    public static function get_site_info() {
1761        static $site_info = null;
1762
1763        if ( $site_info !== null ) {
1764            return $site_info;
1765        }
1766
1767        // Check for a cached value before doing lookup
1768        $stored_site_info = get_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY );
1769        if ( $stored_site_info !== false ) {
1770            return $stored_site_info;
1771        }
1772
1773        $response = self::get_site();
1774        if ( is_wp_error( $response ) ) {
1775            return $response;
1776        }
1777        $site_info = $response->data;
1778        set_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY, $site_info, DAY_IN_SECONDS );
1779
1780        return $site_info;
1781    }
1782
1783    /**
1784     * Returns whether a site has been determined "commercial" or not.
1785     *
1786     * @return bool|null
1787     */
1788    public static function is_commercial_site() {
1789        if ( is_wp_error( self::$site_info ) ) {
1790            return null;
1791        }
1792
1793        return empty( self::$site_info->options->is_commercial ) ? false : self::$site_info->options->is_commercial;
1794    }
1795
1796    /**
1797     * Check if site is registered (has been connected before).
1798     *
1799     * @return bool
1800     */
1801    public static function is_registered() {
1802        return (bool) \Jetpack_Options::get_option( 'id' );
1803    }
1804
1805    /**
1806     * Dismiss the welcome banner.
1807     *
1808     * @return \WP_REST_Response
1809     */
1810    public static function dismiss_welcome_banner() {
1811        \Jetpack_Options::update_option( 'dismissed_welcome_banner', true );
1812        return rest_ensure_response( array( 'success' => true ) );
1813    }
1814
1815    /**
1816     * Returns "yes" if the site has file write access to the plugins folder, "no" otherwise.
1817     *
1818     * @return string
1819     **/
1820    public static function has_file_system_write_access() {
1821
1822        $cache = get_transient( 'my_jetpack_write_access' );
1823
1824        if ( false !== $cache ) {
1825            return $cache;
1826        }
1827
1828        if ( ! function_exists( 'get_filesystem_method' ) ) {
1829            require_once ABSPATH . 'wp-admin/includes/file.php';
1830        }
1831
1832        require_once ABSPATH . 'wp-admin/includes/template.php';
1833
1834        $write_access = 'no';
1835
1836        $filesystem_method = get_filesystem_method( array(), WP_PLUGIN_DIR );
1837        if ( 'direct' === $filesystem_method ) {
1838            $write_access = 'yes';
1839        }
1840
1841        if ( 'no' === $write_access ) {
1842            ob_start();
1843            $filesystem_credentials_are_stored = request_filesystem_credentials( self_admin_url() );
1844            ob_end_clean();
1845
1846            if ( $filesystem_credentials_are_stored ) {
1847                $write_access = 'yes';
1848            }
1849        }
1850
1851        set_transient( 'my_jetpack_write_access', $write_access, 30 * MINUTE_IN_SECONDS );
1852
1853        return $write_access;
1854    }
1855
1856    /**
1857     * Get container IDC for the IDC screen.
1858     *
1859     * @return string
1860     */
1861    public static function get_idc_container_id() {
1862        return static::IDC_CONTAINER_ID;
1863    }
1864
1865    /**
1866     * Conditionally append the red bubble notification to the "Jetpack" menu item if there are alerts to show.
1867     *
1868     * On My Jetpack page: Uses blocking behavior to fetch fresh data.
1869     * On other admin pages: Uses cached data only to avoid blocking, with async JS fetch if cache is empty.
1870     *
1871     * @return void
1872     */
1873    public static function maybe_show_red_bubble() {
1874        global $pagenow;
1875
1876        // Don't show red bubble alerts for non-admin users
1877        // These alerts are generally only actionable for admins
1878        if ( ! current_user_can( 'manage_options' ) ) {
1879            return;
1880        }
1881
1882        // Don't show any red bubbles when Jetpack is disconnected
1883        // Users can't act on most alerts without a connection
1884        $connection = new Connection_Manager();
1885        if ( ! $connection->is_connected() ) {
1886            return;
1887        }
1888
1889        // Check if we're on the My Jetpack page.
1890        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1891        $page               = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
1892        $is_my_jetpack_page = $pagenow === 'admin.php' && $page === 'my-jetpack';
1893
1894        if ( $is_my_jetpack_page ) {
1895            // On My Jetpack page: use blocking behavior for fresh data.
1896            add_filter( 'my_jetpack_red_bubble_notification_slugs', array( Red_Bubble_Notifications::class, 'add_red_bubble_alerts' ) );
1897            $red_bubble_alerts = Red_Bubble_Notifications::get_red_bubble_alerts();
1898        } else {
1899            // On other pages: use cached data only to avoid blocking.
1900            $cached_alerts = Red_Bubble_Notifications::get_cached_alerts();
1901
1902            if ( false === $cached_alerts ) {
1903                // No cache: warm it asynchronously via JS. Register a hidden zero-count
1904                // placeholder so Menu_Renderer emits a `my-jetpack` badge element the
1905                // warmer can reveal (see async-notification-bubble.ts) without a reload.
1906                Menu_Badges::init(); // idempotent; wires the renderer.
1907                Notification_Counts::register(
1908                    'my-jetpack',
1909                    array(
1910                        'menu_slug' => 'my-jetpack',
1911                        'count'     => 0,
1912                        'type'      => 'count',
1913                    )
1914                );
1915                add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_red_bubble_script' ) );
1916                return;
1917            }
1918
1919            $red_bubble_alerts = $cached_alerts;
1920        }
1921
1922        // Filter out silent alerts.
1923        $red_bubble_alerts = array_filter(
1924            $red_bubble_alerts,
1925            function ( $alert ) {
1926                return empty( $alert['is_silent'] );
1927            }
1928        );
1929
1930        // Report each non-silent alert to the central menu-badges registry as an
1931        // attention entry (count 1). The registry + renderer own the badge.
1932        Menu_Badges::init(); // idempotent; wires the renderer.
1933
1934        // Connection errors are owned by the Error Handler (the single source of truth):
1935        // surface any it reports for this viewer as their own attention entry. Read live
1936        // rather than from the red-bubble transient, since that cache is not viewer-keyed.
1937        $has_connection_error = self::has_connection_error();
1938        if ( $has_connection_error ) {
1939            Notification_Counts::register(
1940                'my-jetpack-connection-error',
1941                array(
1942                    'menu_slug' => 'my-jetpack',
1943                    'type'      => 'attention',
1944                )
1945            );
1946        }
1947
1948        $counted_elsewhere = self::get_alerts_counted_elsewhere();
1949        foreach ( array_keys( $red_bubble_alerts ) as $slug ) {
1950            if ( in_array( $slug, $counted_elsewhere, true ) ) {
1951                continue;
1952            }
1953            // The missing-connection slug and a connection error describe the same broken
1954            // connection; count it once (the error, above, is the more specific signal).
1955            if ( $has_connection_error && 'missing-connection' === $slug ) {
1956                continue;
1957            }
1958            Notification_Counts::register(
1959                'my-jetpack-' . $slug,
1960                array(
1961                    'menu_slug' => 'my-jetpack',
1962                    'type'      => 'attention',
1963                )
1964            );
1965        }
1966    }
1967
1968    /**
1969     * Whether the Connection Error Handler reports a displayable connection error
1970     * for the current viewer.
1971     *
1972     * Read live (not via the red-bubble transient): get_displayable_errors() is a
1973     * cached option read that the Error Handler already scopes and caches per viewer.
1974     * Guarded for the mid-plugin-update window, where a stale connection package
1975     * predating the method can be loaded.
1976     *
1977     * @return bool
1978     */
1979    private static function has_connection_error() {
1980        if ( ! class_exists( Connection_Error_Handler::class ) || ! method_exists( Connection_Error_Handler::class, 'get_displayable_errors' ) ) {
1981            return false;
1982        }
1983        return ! empty( Connection_Error_Handler::get_instance()->get_displayable_errors() );
1984    }
1985
1986    /**
1987     * Alerts left out of the My Jetpack badge because another menu item already counts them.
1988     *
1989     * The Protect page reports its own count, from the standalone plugin (class-jetpack-protect.php::admin_page_init())
1990     * or the Protect package's dashboard; without either, nobody else counts the threats, so they must stay.
1991     *
1992     * @return string[] Alert slugs.
1993     */
1994    private static function get_alerts_counted_elsewhere() {
1995        return Products\Protect::has_protect_page() ? array( 'protect_has_threats' ) : array();
1996    }
1997
1998    /**
1999     * Enqueue the notification bubble script.
2000     * Fetches fresh alert data via REST API without blocking page load.
2001     *
2002     * @return void
2003     */
2004    public static function enqueue_red_bubble_script() {
2005        Assets::register_script(
2006            'my-jetpack-notification-bubble',
2007            '../build/async-notification-bubble.js',
2008            __FILE__,
2009            array(
2010                'enqueue'   => true,
2011                'in_footer' => true,
2012            )
2013        );
2014        wp_localize_script(
2015            'my-jetpack-notification-bubble',
2016            'myJetpackNotificationBubble',
2017            array( 'excludedAlerts' => self::get_alerts_counted_elsewhere() )
2018        );
2019    }
2020
2021    /**
2022     * Get list of module names sorted by their recommendation score
2023     *
2024     * @return array|null
2025     */
2026    public static function get_recommended_modules() {
2027        $recommendations_evaluation = \Jetpack_Options::get_option( 'recommendations_evaluation', null );
2028
2029        if ( empty( $recommendations_evaluation ) || ! is_array( $recommendations_evaluation ) ) {
2030            return null;
2031        }
2032
2033        arsort( $recommendations_evaluation ); // Sort by scores in descending order
2034
2035        return array_keys( $recommendations_evaluation ); // Get only module names
2036    }
2037}