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