Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.30% covered (warning)
82.30%
200 / 243
68.42% covered (warning)
68.42%
13 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Connector
82.30% covered (warning)
82.30%
200 / 243
68.42% covered (warning)
68.42%
13 / 19
130.91
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 register_connector
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_script_module
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 get_connector_data
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
8
 add_identity_crisis_data
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 get_protected_owner_card_state
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 should_enqueue_protected_owner_dialogs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_current_user_data
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
 get_connection_owner_data
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 resolve_user_fields
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 is_connectors_screen
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_connectors_page_path
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 store_auth_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 consume_auth_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_connected_plugins_data
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 get_connected_plugin_families
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 get_connector_logo_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 get_inline_connector_logo_url
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 get_plugin_logo_url
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
1<?php
2/**
3 * Jetpack connector card for the WP core Connectors screen.
4 *
5 * Registers a connector in the WP 7.0+ Connectors registry and enqueues
6 * a script module that provides a custom render function with connection
7 * details (owner, connected plugins, disconnect).
8 *
9 * @package automattic/jetpack-connection
10 */
11
12namespace Automattic\Jetpack\Connection;
13
14use Automattic\Jetpack\Assets;
15use Automattic\Jetpack\Identity_Crisis;
16use Automattic\Jetpack\Modules;
17use Automattic\Jetpack\Status;
18use Automattic\Jetpack\Status\Host;
19
20/**
21 * Jetpack connector card handler.
22 *
23 * @since 8.2.0
24 */
25class Jetpack_Connector {
26
27    /**
28     * Whether the connector has been initialized.
29     *
30     * @var bool
31     */
32    private static $initialized = false;
33
34    /**
35     * Script module identifier.
36     *
37     * @var string
38     */
39    const MODULE_ID = '@automattic/jetpack-connection-connectors';
40
41    /**
42     * Screen ID assigned by WordPress to the Gutenberg plugin's connectors submenu page.
43     *
44     * @var string
45     */
46    const GUTENBERG_CONNECTORS_SCREEN_ID = 'settings_page_options-connectors-wp-admin';
47
48    /**
49     * Page slug registered by the Gutenberg plugin for the connectors submenu page.
50     *
51     * @var string
52     */
53    const GUTENBERG_CONNECTORS_PAGE_SLUG = 'options-connectors-wp-admin';
54
55    /**
56     * Initialize the connector.
57     */
58    public static function init() {
59        if ( static::$initialized ) {
60            return;
61        }
62        static::$initialized = true;
63
64        add_action( 'wp_connectors_init', array( static::class, 'register_connector' ), 20 );
65        add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_script_module' ) );
66        add_action( 'jetpack_client_authorize_error', array( static::class, 'store_auth_error' ) );
67    }
68
69    /**
70     * Register Jetpack as a connector in the WP core Connectors screen.
71     *
72     * The wp_connectors_init action is available in WordPress 7.0+.
73     * On older versions this action never fires, so the hook is safely a no-op.
74     *
75     * @since 8.2.0
76     *
77     * @param \WP_Connector_Registry $registry Connector registry instance.
78     */
79    public static function register_connector( $registry ) {
80        $registry->register(
81            'wordpress_com',
82            array(
83                'name'           => 'Jetpack Connection',
84                'description'    => __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' ),
85                'type'           => 'cloud_service',
86                'logo_url'       => static::get_connector_logo_url(),
87                'authentication' => array(
88                    'method' => 'none',
89                ),
90            )
91        );
92    }
93
94    /**
95     * Enqueue the connectors card script module on the Settings > Connectors page.
96     *
97     * @since 8.2.0
98     */
99    public static function enqueue_script_module() {
100        $screen = get_current_screen();
101
102        if ( ! $screen || ! static::is_connectors_screen( $screen ) ) {
103            return;
104        }
105
106        if ( ! class_exists( 'WP_Connector_Registry' ) ) {
107            return;
108        }
109
110        $css_path = __DIR__ . '/css/connectors-card.css';
111        wp_enqueue_style(
112            'jetpack-connector-card',
113            plugins_url( 'css/connectors-card.css', __FILE__ ),
114            array(),
115            (string) @filemtime( $css_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
116        );
117
118        $js_path = __DIR__ . '/js/connectors-card.js';
119        wp_register_script_module(
120            static::MODULE_ID,
121            plugins_url( 'js/connectors-card.js', __FILE__ ),
122            array(
123                array(
124                    'id'     => '@wordpress/connectors',
125                    'import' => 'static',
126                ),
127            ),
128            (string) @filemtime( $js_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
129        );
130        wp_enqueue_script_module( static::MODULE_ID );
131
132        // Assets::enqueue_script also loads the stylesheet registered with the handle.
133        if ( static::should_enqueue_protected_owner_dialogs( new Manager() ) ) {
134            Assets::enqueue_script( 'jetpack-connection' );
135        }
136
137        add_filter(
138            'script_module_data_' . static::MODULE_ID,
139            array( static::class, 'get_connector_data' )
140        );
141    }
142
143    /**
144     * Build the data passed to the script module via the script_module_data_ filter.
145     *
146     * @since 8.2.0
147     *
148     * @param array $data Existing script module data.
149     * @return array Filtered script module data.
150     */
151    public static function get_connector_data( $data ) {
152        $manager       = new Manager();
153        $is_registered = $manager->is_connected();
154        $is_connected  = $is_registered && $manager->has_connected_owner();
155
156        $data['isConnected']          = $is_connected;
157        $data['isRegistered']         = $is_registered;
158        $data['isOfflineMode']        = ( new Status() )->is_offline_mode();
159        $data['isFirstConnection']    = ! $is_registered && ! (bool) \Jetpack_Options::get_option( 'id' );
160        $data['apiRoot']              = esc_url_raw( rest_url() );
161        $data['apiNonce']             = wp_create_nonce( 'wp_rest' );
162        $data['redirectUri']          = static::get_connectors_page_path();
163        $data['connectorName']        = 'Jetpack Connection';
164        $data['connectorDescription'] = __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' );
165        $data['connectorLogoUrl']     = static::get_connector_logo_url();
166
167        $data['connectedPlugins'] = static::get_connected_plugins_data( $manager );
168
169        if ( $is_registered ) {
170            $data['siteDetails'] = array(
171                'blogId'  => (int) \Jetpack_Options::get_option( 'id' ),
172                'siteUrl' => site_url(),
173                'homeUrl' => home_url(),
174            );
175
176            if ( in_array( 'jetpack', array_column( $data['connectedPlugins'], 'slug' ), true ) ) {
177                $data['ssoStatus'] = ( new Modules() )->is_active( 'sso', false );
178            }
179
180            static::add_identity_crisis_data( $data );
181        }
182
183        if ( $is_connected ) {
184            $data['currentUser']       = static::get_current_user_data( $manager );
185            $data['connectionOwner']   = static::get_connection_owner_data( $manager );
186            $data['connectedUsersUrl'] = Users_Connection_Admin::get_connected_view_url();
187        }
188
189        $protected_owner = static::get_protected_owner_card_state( $manager );
190        if ( null !== $protected_owner ) {
191            $data['protectedOwner'] = $protected_owner;
192        }
193
194        $host              = new Host();
195        $data['isWoaSite'] = $host->is_woa_site();
196        $data['isVipSite'] = $host->is_vip_site();
197
198        $auth_error = static::consume_auth_error();
199        if ( $auth_error ) {
200            $data['authError'] = $auth_error;
201        }
202
203        return $data;
204    }
205
206    /**
207     * Add Jetpack Identity Crisis (Safe Mode) data to the script module data.
208     *
209     * The connector card uses this to swap the status badge to "Safe Mode" and
210     * to render IDC resolution options (migrate / start fresh / stay in safe
211     * mode) in the expanded details. Mirrors the data assembled by
212     * \Automattic\Jetpack\IdentityCrisis\UI::get_initial_state_data().
213     *
214     * @since 8.7.0
215     *
216     * @param array $data Script module data passed by reference.
217     */
218    private static function add_identity_crisis_data( &$data ) {
219        if ( ! class_exists( Identity_Crisis::class ) ) {
220            return;
221        }
222
223        $in_safe_mode = ( new Status() )->in_safe_mode();
224
225        $data['isInSafeMode'] = $in_safe_mode;
226
227        if ( ! $in_safe_mode ) {
228            return;
229        }
230
231        $idc_urls = Identity_Crisis::get_mismatched_urls();
232
233        $data['isSafeModeConfirmed'] = (bool) Identity_Crisis::$is_safe_mode_confirmed;
234        $data['idc']                 = array(
235            'currentUrl'                     => ( is_array( $idc_urls ) && array_key_exists( 'current_url', $idc_urls ) ) ? $idc_urls['current_url'] : home_url(),
236            'wpcomHomeUrl'                   => ( is_array( $idc_urls ) && array_key_exists( 'wpcom_url', $idc_urls ) ) ? $idc_urls['wpcom_url'] : '',
237            'isDevelopmentSite'              => (bool) Status::is_development_site(),
238            'possibleDynamicSiteUrlDetected' => (bool) Identity_Crisis::detect_possible_dynamic_site_url(),
239        );
240    }
241
242    /**
243     * Protected-owner state for the connector card.
244     *
245     * Omitted when nothing requests a protected owner and no anchor is stored,
246     * so the card keeps its current account sections. The status is
247     * Manager::resolve_protected_owner_state(), which the card switches on.
248     *
249     * `viewerIsConfirmedOwner` is that method's `is_current_user_the_po`. It tells the recovery
250     * copy apart: an owner whose own token broke is asked to reconnect, not to connect.
251     *
252     * @since 9.9.0
253     *
254     * @param Manager $manager Connection manager instance.
255     * @return array{status: string, viewerIsConfirmedOwner: bool}|null
256     */
257    private static function get_protected_owner_card_state( $manager ) {
258        $requires = $manager->requires_protected_owner();
259        $anchor   = Protected_Owner::get_locked();
260
261        if ( ! $requires && ! $anchor ) {
262            return null;
263        }
264
265        $state = $manager->resolve_protected_owner_state();
266
267        // No anchor and this viewer cannot confirm: leave the card as it is.
268        if ( ! $anchor && Manager::PO_STATE_NOT_ELIGIBLE === $state['status'] ) {
269            return null;
270        }
271
272        return array(
273            'status'                 => $state['status'],
274            'viewerIsConfirmedOwner' => $state['is_current_user_the_po'],
275        );
276    }
277
278    /**
279     * Whether the card can open one of the shared protected-owner dialogs.
280     *
281     * Confirming is only reachable for a connected administrator, on a site that has asked for a
282     * protected owner and does not have one yet. Releasing is only reachable for the confirmed
283     * owner, and deliberately does not depend on a consumer still asking: a site has to be able
284     * to give up a lock after the plugin that wanted it is gone.
285     *
286     * Both read the one state call rather than asking again, so neither adds a WordPress.com
287     * round trip to a screen load.
288     *
289     * The card always uses the package dialogs. `jetpack_connection_protected_owner_default_ui`
290     * is for a consumer's own surface, not this one.
291     *
292     * @since 9.9.0
293     *
294     * @param Manager $manager Connection manager instance.
295     * @return bool
296     */
297    private static function should_enqueue_protected_owner_dialogs( $manager ) {
298        $state = $manager->resolve_protected_owner_state();
299
300        if ( $manager->requires_protected_owner() && Manager::PO_STATE_CAN_ESTABLISH === $state['status'] ) {
301            return true;
302        }
303
304        // `RE_EVALUATE` is the anchored state where the connection owner matches the anchor, so
305        // pinning the viewer to that owner is the same gate the release endpoint applies — a
306        // matching binding alone would offer a dialog the endpoint then refuses.
307        return Manager::PO_STATE_RE_EVALUATE === $state['status']
308            && get_current_user_id() === (int) $manager->get_connection_owner_id();
309    }
310
311    /**
312     * Get the current (logged-in) user's connection details.
313     *
314     * @param Manager $manager Connection manager instance.
315     * @return array|null Current user data or null if not connected.
316     */
317    private static function get_current_user_data( $manager ) {
318        $user_id = get_current_user_id();
319
320        if ( ! $user_id || ! $manager->is_user_connected( $user_id ) ) {
321            return null;
322        }
323
324        $user      = get_userdata( $user_id );
325        $user_info = static::resolve_user_fields( $user, $manager->get_connected_user_data( $user_id ) );
326        $is_owner  = $manager->is_connection_owner( $user_id );
327
328        $has_other_connected_users = false;
329        if ( $is_owner ) {
330            $connected_users           = $manager->get_connected_users( 'any', 2 );
331            $has_other_connected_users = count( $connected_users ) > 1;
332        }
333
334        return array_merge(
335            $user_info,
336            array(
337                'isOwner'                => $is_owner,
338                'hasOtherConnectedUsers' => $has_other_connected_users,
339            )
340        );
341    }
342
343    /**
344     * Get the connection owner details for the script module.
345     *
346     * @param Manager $manager Connection manager instance.
347     * @return array|null Owner data or null if unavailable.
348     */
349    private static function get_connection_owner_data( $manager ) {
350        $owner = $manager->get_connection_owner();
351
352        if ( false === $owner ) {
353            return null;
354        }
355
356        $fields = static::resolve_user_fields( $owner, $manager->get_connected_user_data( $owner->ID ) );
357
358        $fields['localLogin'] = $owner->user_login;
359
360        return $fields;
361    }
362
363    /**
364     * Merge local WP user fields with WordPress.com user data.
365     *
366     * WPCOM values take precedence when available. Returns the common
367     * user shape used by both currentUser and connectionOwner.
368     *
369     * @param \WP_User|false $wp_user        Local WordPress user object (false if unavailable).
370     * @param array|false    $wpcom_user_data WPCOM user data from the connection manager.
371     * @return array User data with displayName, login, email, and avatar.
372     */
373    private static function resolve_user_fields( $wp_user, $wpcom_user_data ) {
374        $display_name = $wp_user ? $wp_user->display_name : '';
375        $login        = $wp_user ? $wp_user->user_login : '';
376        $email        = $wp_user ? $wp_user->user_email : '';
377
378        if ( is_array( $wpcom_user_data ) ) {
379            if ( ! empty( $wpcom_user_data['display_name'] ) ) {
380                $display_name = $wpcom_user_data['display_name'];
381            }
382            if ( ! empty( $wpcom_user_data['login'] ) ) {
383                $login = $wpcom_user_data['login'];
384            }
385            if ( ! empty( $wpcom_user_data['email'] ) ) {
386                $email = $wpcom_user_data['email'];
387            }
388        }
389
390        $user_id = $wp_user ? $wp_user->ID : 0;
391
392        return array(
393            'displayName' => $display_name,
394            'login'       => $login,
395            'email'       => $email,
396            'avatar'      => $user_id
397                ? get_avatar_url(
398                    $user_id,
399                    array(
400                        'size'    => 48,
401                        'default' => 'mysteryman',
402                    )
403                )
404                : '',
405        );
406    }
407
408    /**
409     * Check whether the given screen is the Connectors settings page.
410     *
411     * Handles both WP 7.0 core (`options-connectors`) and the Gutenberg
412     * plugin (`settings_page_options-connectors-wp-admin`).
413     *
414     * @param \WP_Screen $screen Current admin screen.
415     * @return bool
416     */
417    private static function is_connectors_screen( $screen ) {
418        return 'options-connectors' === $screen->id
419            || static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id;
420    }
421
422    /**
423     * Return the admin-relative path for the Connectors page.
424     *
425     * WP 7.0 core uses the standalone `options-connectors.php` file while
426     * the Gutenberg plugin registers a submenu page under options-general.php
427     * with slug `options-connectors-wp-admin`. Both set parent_file to
428     * `options-general.php` for menu highlighting, so we distinguish them by
429     * checking the actual script filename being served.
430     *
431     * Note: for the Gutenberg case we use the registered page slug directly,
432     * not `$screen->id`. WordPress auto-prefixes screen IDs for submenu pages
433     * (e.g. `settings_page_options-connectors-wp-admin`), so using `$screen->id`
434     * as the `page=` parameter produces an invalid URL.
435     *
436     * The result is suitable for the `redirect_uri` parameter accepted by the
437     * `jetpack/v4/connection/register` REST endpoint (which wraps it in `admin_url()`).
438     *
439     * @return string Admin-relative path, e.g. 'options-connectors.php'.
440     */
441    private static function get_connectors_page_path() {
442        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared against a hardcoded string.
443        $script = isset( $_SERVER['SCRIPT_NAME'] ) ? wp_basename( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : '';
444
445        if ( 'options-connectors.php' === $script ) {
446            return 'options-connectors.php';
447        }
448
449        // Gutenberg plugin registers the page under options-general.php.
450        $screen = get_current_screen();
451        if ( $screen && static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id ) {
452            return 'options-general.php?page=' . static::GUTENBERG_CONNECTORS_PAGE_SLUG;
453        }
454
455        return 'options-connectors.php';
456    }
457
458    /**
459     * Store an authorization error in a short-lived transient.
460     *
461     * Hooked to `jetpack_client_authorize_error` which fires when
462     * the auth webhook fails. The transient is read on the next
463     * Connectors page load so the JS card can display the error.
464     *
465     * @since 8.2.0
466     *
467     * @param \WP_Error $error Authorization error.
468     */
469    public static function store_auth_error( $error ) {
470        if ( is_wp_error( $error ) ) {
471            $user_id = get_current_user_id();
472            if ( $user_id ) {
473                set_transient(
474                    'jetpack_connector_auth_error_' . $user_id,
475                    $error->get_error_message(),
476                    60
477                );
478            }
479        }
480    }
481
482    /**
483     * Read and delete a stored authorization error for the current user.
484     *
485     * @return string|false Error message or false if none.
486     */
487    private static function consume_auth_error() {
488        $user_id = get_current_user_id();
489        if ( ! $user_id ) {
490            return false;
491        }
492
493        $key   = 'jetpack_connector_auth_error_' . $user_id;
494        $error = get_transient( $key );
495        if ( false !== $error ) {
496            delete_transient( $key );
497        }
498
499        return $error;
500    }
501
502    /**
503     * Get connected plugins data for the script module.
504     *
505     * @param Manager $manager Connection manager instance.
506     * @return array List of connected plugin data.
507     */
508    private static function get_connected_plugins_data( $manager ) {
509        $plugins = $manager->get_connected_plugins();
510
511        if ( is_wp_error( $plugins ) || ! is_array( $plugins ) ) {
512            return array();
513        }
514
515        $result = array();
516
517        foreach ( $plugins as $slug => $plugin_data ) {
518            $name = $plugin_data['name'] ?? $slug;
519
520            $entry = array(
521                'name' => $name,
522                'slug' => $slug,
523            );
524
525            $logo_url = static::get_plugin_logo_url( $slug );
526            if ( $logo_url ) {
527                $entry['logoUrl'] = $logo_url;
528            }
529
530            $result[] = $entry;
531        }
532
533        return $result;
534    }
535
536    /**
537     * Detect which plugin families are using the connection.
538     *
539     * @since 8.5.0
540     *
541     * @return array{has_woo: bool, has_a4a: bool}
542     */
543    public static function get_connected_plugin_families() {
544        $plugins = Plugin_Storage::get_all();
545
546        $has_woo = false;
547        $has_a4a = false;
548
549        if ( is_array( $plugins ) ) {
550            foreach ( array_keys( $plugins ) as $slug ) {
551                if ( str_starts_with( $slug, 'woocommerce' ) ) {
552                    $has_woo = true;
553                }
554                if ( str_starts_with( $slug, 'automattic' ) ) {
555                    $has_a4a = true;
556                }
557            }
558        }
559
560        return array(
561            'has_woo' => $has_woo,
562            'has_a4a' => $has_a4a,
563        );
564    }
565
566    /**
567     * Determine the connector card logo based on which plugin families are connected.
568     *
569     * Priority:
570     * 1. Both Woo-family and A4A plugins → jetpack-connect-all.svg
571     * 2. Woo-family only                 → jetpack-connect-woo.svg
572     * 3. A4A only                        → jetpack-connect-a8c.svg
573     * 4. Default (Jetpack only or other) → jetpack-connect.svg
574     *
575     * @since 8.3.2
576     *
577     * @return string Logo URL.
578     */
579    public static function get_connector_logo_url() {
580        $families = self::get_connected_plugin_families();
581
582        if ( $families['has_woo'] && $families['has_a4a'] ) {
583            return plugins_url( 'images/jetpack-connect-all.svg', __FILE__ );
584        }
585
586        if ( $families['has_woo'] ) {
587            return plugins_url( 'images/jetpack-connect-woo.svg', __FILE__ );
588        }
589
590        if ( $families['has_a4a'] ) {
591            return plugins_url( 'images/jetpack-connect-a8c.svg', __FILE__ );
592        }
593
594        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
595    }
596
597    /**
598     * Get the inline (single-row) connector logo for use in compact contexts like table cells.
599     *
600     * All circles are arranged horizontally in a single row, unlike the card
601     * logos which stack circles vertically for 3+ plugins.
602     *
603     * @since 8.5.0
604     *
605     * @return string Logo URL.
606     */
607    public static function get_inline_connector_logo_url() {
608        $families = self::get_connected_plugin_families();
609
610        if ( $families['has_woo'] && $families['has_a4a'] ) {
611            return plugins_url( 'images/jetpack-connect-all-inline.svg', __FILE__ );
612        }
613
614        if ( $families['has_woo'] ) {
615            return plugins_url( 'images/jetpack-connect-woo-inline.svg', __FILE__ );
616        }
617
618        if ( $families['has_a4a'] ) {
619            return plugins_url( 'images/jetpack-connect-a8c-inline.svg', __FILE__ );
620        }
621
622        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
623    }
624
625    /**
626     * Map a plugin slug to a brand logo URL.
627     *
628     * Jetpack-family plugins get the Jetpack mark, WooCommerce-family
629     * plugins get the Woo mark, and Automattic for Agencies gets the
630     * Automattic mark. Unknown slugs fall through to the
631     * `jetpack_connection_plugin_logo_url` filter so that third-party
632     * plugins can register their own logo. Only SVG URLs are accepted
633     * to keep the icons sharp at every display density.
634     *
635     * @since 8.3.2
636     *
637     * @param string $slug Plugin slug.
638     * @return string|null Logo URL or null.
639     */
640    private static function get_plugin_logo_url( $slug ) {
641        if ( str_starts_with( $slug, 'jetpack' ) ) {
642            return plugins_url( 'images/jetpack-icon.svg', __FILE__ ); // str_starts_with() is polyfilled by WP since 5.9; this code only runs on WP 7.0+.
643        }
644
645        if ( str_starts_with( $slug, 'woocommerce' ) ) {
646            return plugins_url( 'images/woo-icon.svg', __FILE__ );
647        }
648
649        if ( str_starts_with( $slug, 'automattic' ) ) {
650            return plugins_url( 'images/automattic-icon.svg', __FILE__ );
651        }
652
653        /**
654         * Filters a map of plugin slugs to custom logo URLs for the
655         * Settings → Connectors card.
656         *
657         * Add entries as `$slug => $url` pairs. URLs must point to an
658         * SVG file (`.svg` extension required); non-SVG values are
659         * silently ignored and the generic fallback icon is shown.
660         *
661         * Example:
662         *
663         *     add_filter( 'jetpack_connection_plugin_logos', function ( $logos ) {
664         *         $logos['my-plugin'] = plugins_url( 'assets/logo.svg', __FILE__ );
665         *         return $logos;
666         *     } );
667         *
668         * @since 8.3.2
669         *
670         * @param array<string,string> $logos Map of plugin slug to SVG URL.
671         */
672        $logos = apply_filters( 'jetpack_connection_plugin_logos', array() );
673
674        if ( isset( $logos[ $slug ] ) && is_string( $logos[ $slug ] ) && str_ends_with( strtolower( $logos[ $slug ] ), '.svg' ) ) {
675            return esc_url( $logos[ $slug ] );
676        }
677
678        return null;
679    }
680}