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