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