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