Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.98% covered (success)
93.98%
234 / 249
70.00% covered (warning)
70.00%
14 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Connector
93.98% covered (success)
93.98%
234 / 249
70.00% covered (warning)
70.00%
14 / 20
96.97
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
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
8.05
 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
 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     * 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 card's globals 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        // The card reads these dialogs from window.JetpackConnection, so the asset file does not list the script.
156        if ( static::should_enqueue_protected_owner_dialogs( new Manager() ) ) {
157            Assets::enqueue_script( 'jetpack-connection' );
158        }
159
160        add_filter(
161            'script_module_data_' . static::MODULE_ID,
162            array( static::class, 'get_connector_data' )
163        );
164    }
165
166    /**
167     * Read the asset file the build emits beside the card's script module.
168     *
169     * @since 9.11.0
170     *
171     * @param string $module_path Path of the built module, without the extension.
172     * @return array|null Asset data, or null if the module or its asset file is missing, or the asset is invalid.
173     */
174    private static function get_module_asset( $module_path ) {
175        $asset_path = $module_path . '.asset.php';
176        if ( ! file_exists( $module_path . '.js' ) || ! file_exists( $asset_path ) ) {
177            return null;
178        }
179
180        $asset = require $asset_path;
181
182        return is_array( $asset ) ? $asset : null;
183    }
184
185    /**
186     * Build the data passed to the script module via the script_module_data_ filter.
187     *
188     * @since 8.2.0
189     *
190     * @param array $data Existing script module data.
191     * @return array Filtered script module data.
192     */
193    public static function get_connector_data( $data ) {
194        $manager       = new Manager();
195        $is_registered = $manager->is_connected();
196        $is_connected  = $is_registered && $manager->has_connected_owner();
197
198        $data['isConnected']          = $is_connected;
199        $data['isRegistered']         = $is_registered;
200        $data['isOfflineMode']        = ( new Status() )->is_offline_mode();
201        $data['isFirstConnection']    = ! $is_registered && ! (bool) \Jetpack_Options::get_option( 'id' );
202        $data['apiRoot']              = esc_url_raw( rest_url() );
203        $data['apiNonce']             = wp_create_nonce( 'wp_rest' );
204        $data['redirectUri']          = static::get_connectors_page_path();
205        $data['connectorName']        = 'Jetpack Connection';
206        $data['connectorDescription'] = __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' );
207        $data['connectorLogoUrl']     = static::get_connector_logo_url();
208
209        $data['connectedPlugins'] = static::get_connected_plugins_data( $manager );
210
211        if ( $is_registered ) {
212            $data['siteDetails'] = array(
213                'blogId'  => (int) \Jetpack_Options::get_option( 'id' ),
214                'siteUrl' => site_url(),
215                'homeUrl' => home_url(),
216            );
217
218            if ( in_array( 'jetpack', array_column( $data['connectedPlugins'], 'slug' ), true ) ) {
219                $data['ssoStatus'] = ( new Modules() )->is_active( 'sso', false );
220            }
221
222            static::add_identity_crisis_data( $data );
223        }
224
225        if ( $is_connected ) {
226            $data['currentUser']       = static::get_current_user_data( $manager );
227            $data['connectionOwner']   = static::get_connection_owner_data( $manager );
228            $data['connectedUsersUrl'] = Users_Connection_Admin::get_connected_view_url();
229        }
230
231        $protected_owner = static::get_protected_owner_card_state( $manager );
232        if ( null !== $protected_owner ) {
233            $data['protectedOwner'] = $protected_owner;
234        }
235
236        $host              = new Host();
237        $data['isWoaSite'] = $host->is_woa_site();
238        $data['isVipSite'] = $host->is_vip_site();
239
240        $auth_error = static::consume_auth_error();
241        if ( $auth_error ) {
242            $data['authError'] = $auth_error;
243        }
244
245        return $data;
246    }
247
248    /**
249     * Add Jetpack Identity Crisis (Safe Mode) data to the script module data.
250     *
251     * The connector card uses this to swap the status badge to "Safe Mode" and
252     * to render IDC resolution options (migrate / start fresh / stay in safe
253     * mode) in the expanded details. Mirrors the data assembled by
254     * \Automattic\Jetpack\IdentityCrisis\UI::get_initial_state_data().
255     *
256     * @since 8.7.0
257     *
258     * @param array $data Script module data passed by reference.
259     */
260    private static function add_identity_crisis_data( &$data ) {
261        if ( ! class_exists( Identity_Crisis::class ) ) {
262            return;
263        }
264
265        $in_safe_mode = ( new Status() )->in_safe_mode();
266
267        $data['isInSafeMode'] = $in_safe_mode;
268
269        if ( ! $in_safe_mode ) {
270            return;
271        }
272
273        $idc_urls = Identity_Crisis::get_mismatched_urls();
274
275        $data['isSafeModeConfirmed'] = (bool) Identity_Crisis::$is_safe_mode_confirmed;
276        $data['idc']                 = array(
277            'currentUrl'                     => ( is_array( $idc_urls ) && array_key_exists( 'current_url', $idc_urls ) ) ? $idc_urls['current_url'] : home_url(),
278            'wpcomHomeUrl'                   => ( is_array( $idc_urls ) && array_key_exists( 'wpcom_url', $idc_urls ) ) ? $idc_urls['wpcom_url'] : '',
279            'isDevelopmentSite'              => (bool) Status::is_development_site(),
280            'possibleDynamicSiteUrlDetected' => (bool) Identity_Crisis::detect_possible_dynamic_site_url(),
281        );
282    }
283
284    /**
285     * Protected-owner state for the connector card.
286     *
287     * Omitted when nothing requests a protected owner and no anchor is stored,
288     * so the card keeps its current account sections. The status is
289     * Manager::resolve_protected_owner_state(), which the card switches on.
290     *
291     * `viewerIsConfirmedOwner` is that method's `is_current_user_the_po`. It tells the recovery
292     * copy apart: an owner whose own token broke is asked to reconnect, not to connect.
293     *
294     * @since 9.9.0
295     *
296     * @param Manager $manager Connection manager instance.
297     * @return array{status: string, viewerIsConfirmedOwner: bool}|null
298     */
299    private static function get_protected_owner_card_state( $manager ) {
300        $requires = $manager->requires_protected_owner();
301        $anchor   = Protected_Owner::get_locked();
302
303        if ( ! $requires && ! $anchor ) {
304            return null;
305        }
306
307        $state = $manager->resolve_protected_owner_state();
308
309        // No anchor and this viewer cannot confirm: leave the card as it is.
310        if ( ! $anchor && Manager::PO_STATE_NOT_ELIGIBLE === $state['status'] ) {
311            return null;
312        }
313
314        return array(
315            'status'                 => $state['status'],
316            'viewerIsConfirmedOwner' => $state['is_current_user_the_po'],
317        );
318    }
319
320    /**
321     * Whether the card can open one of the shared protected-owner dialogs.
322     *
323     * Confirming is only reachable for a connected administrator, on a site that has asked for a
324     * protected owner and does not have one yet. Releasing is only reachable for the confirmed
325     * owner, and deliberately does not depend on a consumer still asking: a site has to be able
326     * to give up a lock after the plugin that wanted it is gone.
327     *
328     * Both read the one state call rather than asking again, so neither adds a WordPress.com
329     * round trip to a screen load.
330     *
331     * The card always uses the package dialogs. `jetpack_connection_protected_owner_default_ui`
332     * is for a consumer's own surface, not this one.
333     *
334     * @since 9.9.0
335     *
336     * @param Manager $manager Connection manager instance.
337     * @return bool
338     */
339    private static function should_enqueue_protected_owner_dialogs( $manager ) {
340        $state = $manager->resolve_protected_owner_state();
341
342        if ( $manager->requires_protected_owner() && Manager::PO_STATE_CAN_ESTABLISH === $state['status'] ) {
343            return true;
344        }
345
346        // `RE_EVALUATE` is the anchored state where the connection owner matches the anchor, so
347        // pinning the viewer to that owner is the same gate the release endpoint applies — a
348        // matching binding alone would offer a dialog the endpoint then refuses.
349        return Manager::PO_STATE_RE_EVALUATE === $state['status']
350            && get_current_user_id() === (int) $manager->get_connection_owner_id();
351    }
352
353    /**
354     * Get the current (logged-in) user's connection details.
355     *
356     * @param Manager $manager Connection manager instance.
357     * @return array|null Current user data or null if not connected.
358     */
359    private static function get_current_user_data( $manager ) {
360        $user_id = get_current_user_id();
361
362        if ( ! $user_id || ! $manager->is_user_connected( $user_id ) ) {
363            return null;
364        }
365
366        $user      = get_userdata( $user_id );
367        $user_info = static::resolve_user_fields( $user, $manager->get_connected_user_data( $user_id ) );
368        $is_owner  = $manager->is_connection_owner( $user_id );
369
370        $has_other_connected_users = false;
371        if ( $is_owner ) {
372            $connected_users           = $manager->get_connected_users( 'any', 2 );
373            $has_other_connected_users = count( $connected_users ) > 1;
374        }
375
376        return array_merge(
377            $user_info,
378            array(
379                'isOwner'                => $is_owner,
380                'hasOtherConnectedUsers' => $has_other_connected_users,
381            )
382        );
383    }
384
385    /**
386     * Get the connection owner details for the script module.
387     *
388     * Local fields only: another administrator is named as this site knows them, and resolving
389     * their WordPress.com identity would cost a signed request on every Connectors screen load.
390     *
391     * @param Manager $manager Connection manager instance.
392     * @return array|null Owner data or null if unavailable.
393     */
394    private static function get_connection_owner_data( $manager ) {
395        $owner = $manager->get_connection_owner();
396
397        if ( false === $owner ) {
398            return null;
399        }
400
401        $fields = static::resolve_user_fields( $owner, false );
402
403        $fields['localLogin'] = $owner->user_login;
404
405        return $fields;
406    }
407
408    /**
409     * Merge local WP user fields with WordPress.com user data.
410     *
411     * WPCOM values take precedence when the caller passes them. Returns the common user shape
412     * used by both currentUser and connectionOwner; only currentUser resolves against WPCOM.
413     *
414     * @param \WP_User|false $wp_user        Local WordPress user object (false if unavailable).
415     * @param array|false    $wpcom_user_data WPCOM user data from the connection manager.
416     * @return array User data with displayName, login, email, and avatar.
417     */
418    private static function resolve_user_fields( $wp_user, $wpcom_user_data ) {
419        $display_name = $wp_user ? $wp_user->display_name : '';
420        $login        = $wp_user ? $wp_user->user_login : '';
421        $email        = $wp_user ? $wp_user->user_email : '';
422
423        if ( is_array( $wpcom_user_data ) ) {
424            if ( ! empty( $wpcom_user_data['display_name'] ) ) {
425                $display_name = $wpcom_user_data['display_name'];
426            }
427            if ( ! empty( $wpcom_user_data['login'] ) ) {
428                $login = $wpcom_user_data['login'];
429            }
430            if ( ! empty( $wpcom_user_data['email'] ) ) {
431                $email = $wpcom_user_data['email'];
432            }
433        }
434
435        $user_id = $wp_user ? $wp_user->ID : 0;
436
437        return array(
438            'displayName' => $display_name,
439            'login'       => $login,
440            'email'       => $email,
441            'avatar'      => $user_id
442                ? get_avatar_url(
443                    $user_id,
444                    array(
445                        'size'    => 48,
446                        'default' => 'mysteryman',
447                    )
448                )
449                : '',
450        );
451    }
452
453    /**
454     * Check whether the given screen is the Connectors settings page.
455     *
456     * Handles both WP 7.0 core (`options-connectors`) and the Gutenberg
457     * plugin (`settings_page_options-connectors-wp-admin`).
458     *
459     * @param \WP_Screen $screen Current admin screen.
460     * @return bool
461     */
462    private static function is_connectors_screen( $screen ) {
463        return 'options-connectors' === $screen->id
464            || static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id;
465    }
466
467    /**
468     * Return the admin-relative path for the Connectors page.
469     *
470     * WP 7.0 core uses the standalone `options-connectors.php` file while
471     * the Gutenberg plugin registers a submenu page under options-general.php
472     * with slug `options-connectors-wp-admin`. Both set parent_file to
473     * `options-general.php` for menu highlighting, so we distinguish them by
474     * checking the actual script filename being served.
475     *
476     * Note: for the Gutenberg case we use the registered page slug directly,
477     * not `$screen->id`. WordPress auto-prefixes screen IDs for submenu pages
478     * (e.g. `settings_page_options-connectors-wp-admin`), so using `$screen->id`
479     * as the `page=` parameter produces an invalid URL.
480     *
481     * The result is suitable for the `redirect_uri` parameter accepted by the
482     * `jetpack/v4/connection/register` REST endpoint (which wraps it in `admin_url()`).
483     *
484     * @return string Admin-relative path, e.g. 'options-connectors.php'.
485     */
486    private static function get_connectors_page_path() {
487        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared against a hardcoded string.
488        $script = isset( $_SERVER['SCRIPT_NAME'] ) ? wp_basename( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : '';
489
490        if ( 'options-connectors.php' === $script ) {
491            return 'options-connectors.php';
492        }
493
494        // Gutenberg plugin registers the page under options-general.php.
495        $screen = get_current_screen();
496        if ( $screen && static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id ) {
497            return 'options-general.php?page=' . static::GUTENBERG_CONNECTORS_PAGE_SLUG;
498        }
499
500        return 'options-connectors.php';
501    }
502
503    /**
504     * Store an authorization error in a short-lived transient.
505     *
506     * Hooked to `jetpack_client_authorize_error` which fires when
507     * the auth webhook fails. The transient is read on the next
508     * Connectors page load so the JS card can display the error.
509     *
510     * @since 8.2.0
511     *
512     * @param \WP_Error $error Authorization error.
513     */
514    public static function store_auth_error( $error ) {
515        if ( is_wp_error( $error ) ) {
516            $user_id = get_current_user_id();
517            if ( $user_id ) {
518                set_transient(
519                    'jetpack_connector_auth_error_' . $user_id,
520                    $error->get_error_message(),
521                    60
522                );
523            }
524        }
525    }
526
527    /**
528     * Read and delete a stored authorization error for the current user.
529     *
530     * @return string|false Error message or false if none.
531     */
532    private static function consume_auth_error() {
533        $user_id = get_current_user_id();
534        if ( ! $user_id ) {
535            return false;
536        }
537
538        $key   = 'jetpack_connector_auth_error_' . $user_id;
539        $error = get_transient( $key );
540        if ( false !== $error ) {
541            delete_transient( $key );
542        }
543
544        return $error;
545    }
546
547    /**
548     * Get connected plugins data for the script module.
549     *
550     * @param Manager $manager Connection manager instance.
551     * @return array List of connected plugin data.
552     */
553    private static function get_connected_plugins_data( $manager ) {
554        $plugins = $manager->get_connected_plugins();
555
556        if ( is_wp_error( $plugins ) || ! is_array( $plugins ) ) {
557            return array();
558        }
559
560        $result = array();
561
562        foreach ( $plugins as $slug => $plugin_data ) {
563            $name = $plugin_data['name'] ?? $slug;
564
565            $entry = array(
566                'name' => $name,
567                'slug' => $slug,
568            );
569
570            $logo_url = static::get_plugin_logo_url( $slug );
571            if ( $logo_url ) {
572                $entry['logoUrl'] = $logo_url;
573            }
574
575            $result[] = $entry;
576        }
577
578        return $result;
579    }
580
581    /**
582     * Detect which plugin families are using the connection.
583     *
584     * @since 8.5.0
585     *
586     * @return array{has_woo: bool, has_a4a: bool}
587     */
588    public static function get_connected_plugin_families() {
589        $plugins = Plugin_Storage::get_all();
590
591        $has_woo = false;
592        $has_a4a = false;
593
594        if ( is_array( $plugins ) ) {
595            foreach ( array_keys( $plugins ) as $slug ) {
596                if ( str_starts_with( $slug, 'woocommerce' ) ) {
597                    $has_woo = true;
598                }
599                if ( str_starts_with( $slug, 'automattic' ) ) {
600                    $has_a4a = true;
601                }
602            }
603        }
604
605        return array(
606            'has_woo' => $has_woo,
607            'has_a4a' => $has_a4a,
608        );
609    }
610
611    /**
612     * Determine the connector card logo based on which plugin families are connected.
613     *
614     * Priority:
615     * 1. Both Woo-family and A4A plugins → jetpack-connect-all.svg
616     * 2. Woo-family only                 → jetpack-connect-woo.svg
617     * 3. A4A only                        → jetpack-connect-a8c.svg
618     * 4. Default (Jetpack only or other) → jetpack-connect.svg
619     *
620     * @since 8.3.2
621     *
622     * @return string Logo URL.
623     */
624    public static function get_connector_logo_url() {
625        $families = self::get_connected_plugin_families();
626
627        if ( $families['has_woo'] && $families['has_a4a'] ) {
628            return plugins_url( 'images/jetpack-connect-all.svg', __FILE__ );
629        }
630
631        if ( $families['has_woo'] ) {
632            return plugins_url( 'images/jetpack-connect-woo.svg', __FILE__ );
633        }
634
635        if ( $families['has_a4a'] ) {
636            return plugins_url( 'images/jetpack-connect-a8c.svg', __FILE__ );
637        }
638
639        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
640    }
641
642    /**
643     * Get the inline (single-row) connector logo for use in compact contexts like table cells.
644     *
645     * All circles are arranged horizontally in a single row, unlike the card
646     * logos which stack circles vertically for 3+ plugins.
647     *
648     * @since 8.5.0
649     *
650     * @return string Logo URL.
651     */
652    public static function get_inline_connector_logo_url() {
653        $families = self::get_connected_plugin_families();
654
655        if ( $families['has_woo'] && $families['has_a4a'] ) {
656            return plugins_url( 'images/jetpack-connect-all-inline.svg', __FILE__ );
657        }
658
659        if ( $families['has_woo'] ) {
660            return plugins_url( 'images/jetpack-connect-woo-inline.svg', __FILE__ );
661        }
662
663        if ( $families['has_a4a'] ) {
664            return plugins_url( 'images/jetpack-connect-a8c-inline.svg', __FILE__ );
665        }
666
667        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
668    }
669
670    /**
671     * Map a plugin slug to a brand logo URL.
672     *
673     * Jetpack-family plugins get the Jetpack mark, WooCommerce-family
674     * plugins get the Woo mark, and Automattic for Agencies gets the
675     * Automattic mark. Unknown slugs fall through to the
676     * `jetpack_connection_plugin_logo_url` filter so that third-party
677     * plugins can register their own logo. Only SVG URLs are accepted
678     * to keep the icons sharp at every display density.
679     *
680     * @since 8.3.2
681     *
682     * @param string $slug Plugin slug.
683     * @return string|null Logo URL or null.
684     */
685    private static function get_plugin_logo_url( $slug ) {
686        if ( str_starts_with( $slug, 'jetpack' ) ) {
687            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+.
688        }
689
690        if ( str_starts_with( $slug, 'woocommerce' ) ) {
691            return plugins_url( 'images/woo-icon.svg', __FILE__ );
692        }
693
694        if ( str_starts_with( $slug, 'automattic' ) ) {
695            return plugins_url( 'images/automattic-icon.svg', __FILE__ );
696        }
697
698        /**
699         * Filters a map of plugin slugs to custom logo URLs for the
700         * Settings → Connectors card.
701         *
702         * Add entries as `$slug => $url` pairs. URLs must point to an
703         * SVG file (`.svg` extension required); non-SVG values are
704         * silently ignored and the generic fallback icon is shown.
705         *
706         * Example:
707         *
708         *     add_filter( 'jetpack_connection_plugin_logos', function ( $logos ) {
709         *         $logos['my-plugin'] = plugins_url( 'assets/logo.svg', __FILE__ );
710         *         return $logos;
711         *     } );
712         *
713         * @since 8.3.2
714         *
715         * @param array<string,string> $logos Map of plugin slug to SVG URL.
716         */
717        $logos = apply_filters( 'jetpack_connection_plugin_logos', array() );
718
719        if ( isset( $logos[ $slug ] ) && is_string( $logos[ $slug ] ) && str_ends_with( strtolower( $logos[ $slug ] ), '.svg' ) ) {
720            return esc_url( $logos[ $slug ] );
721        }
722
723        return null;
724    }
725}