Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 227
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
WC_Analytics_Tracking
0.00% covered (danger)
0.00%
0 / 227
0.00% covered (danger)
0.00%
0 / 19
7482
0.00% covered (danger)
0.00%
0 / 1
 record_event
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 add_event_to_queue
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 get_event_queue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 record_tracks_event
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 record_ch_event
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 record_pixel_url
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 queue_pixel_for_batch
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 send_batched_pixels
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_common_properties
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 get_properties
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 get_blog_user_id
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 get_server_details
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
90
 get_blog_details
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 get_session_details
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 get_visitor_id
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 get_user_ip_address
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 get_ip_based_visitor_id
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 get_daily_salt
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 get_device_type
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * WooCommerce Analytics Tracking for tracking frontend events
4 *
5 * This class is designed to work without WooCommerce dependencies,
6 * enabling it to run at the MU-plugin stage without loading WooCommerce to optimize performance.
7 *
8 * @package automattic/woocommerce-analytics
9 */
10
11namespace Automattic\Woocommerce_Analytics;
12
13use Automattic\Jetpack\Device_Detection;
14use Automattic\Jetpack\Device_Detection\User_Agent_Info;
15use WP_Error;
16
17/**
18 * WooCommerce Analytics Tracking class
19 */
20class WC_Analytics_Tracking {
21    /**
22     * Event prefix.
23     *
24     * @var string
25     */
26    const PREFIX = 'woocommerceanalytics_';
27
28    /**
29     * Option name for storing daily salt data.
30     *
31     * @var string
32     */
33    const DAILY_SALT_OPTION = 'woocommerce_analytics_daily_salt';
34
35    /**
36     * Event queue.
37     *
38     * @var array
39     */
40    protected static $event_queue = array();
41
42    /**
43     * Batch pixel queue for batched requests.
44     *
45     * @var array
46     */
47    private static $pixel_batch_queue = array();
48
49    /**
50     * Whether the shutdown hook has been registered.
51     *
52     * @var bool
53     */
54    private static $shutdown_hook_registered = false;
55
56    /**
57     * Cached user IP address for the current request.
58     *
59     * @var string|null
60     */
61    private static $cached_ip = null;
62
63    /**
64     * Cached visitor ID for the current request.
65     *
66     * @var string|null
67     */
68    private static $cached_visitor_id = null;
69
70    /**
71     * Record an event in Tracks and ClickHouse (If enabled).
72     *
73     * @param string $event_name The name of the event.
74     * @param array  $event_properties Custom properties to send with the event.
75     *
76     * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
77     */
78    public static function record_event( $event_name, $event_properties = array() ) {
79        // Check consent before recording any event
80        if ( ! Consent_Manager::has_analytics_consent() ) {
81            return true; // Skip recording.
82        }
83
84        // Skip recording if the request is coming from a bot.
85        if ( User_Agent_Info::is_bot() ) {
86            return true;
87        }
88
89        $prefixed_event_name = self::PREFIX . $event_name;
90        $properties          = self::get_properties( $prefixed_event_name, $event_properties );
91
92        // Record Tracks event.
93        $tracks_error  = null;
94        $tracks_result = self::record_tracks_event( $properties );
95        if ( is_wp_error( $tracks_result ) ) {
96            $tracks_error = $tracks_result;
97        }
98
99        // Record ClickHouse event, if applicable.
100        $ch_error = null;
101        if ( Features::is_clickhouse_enabled() ) {
102            $properties['ch'] = 1;
103            $ch_result        = self::record_ch_event( $properties );
104            if ( is_wp_error( $ch_result ) ) {
105                $ch_error = $ch_result;
106            }
107        }
108
109        // If both failed, return the Tracks error (primary), else the CH error, else true.
110        if ( $tracks_error ) {
111            return $tracks_error;
112        }
113        if ( $ch_error ) {
114            return $ch_error;
115        }
116
117        return true;
118    }
119
120    /**
121     * Queue an event in the event queue which will be processed on the page load in client-side analytics.
122     *
123     * @param string $event_name The name of the event.
124     * @param array  $properties The event properties.
125     */
126    public static function add_event_to_queue( $event_name, $properties = array() ) {
127        self::$event_queue[] = array(
128            'eventName' => $event_name,
129            'props'     => $properties,
130        );
131    }
132
133    /**
134     * Get the event queue.
135     *
136     * @return array The event queue.
137     */
138    public static function get_event_queue() {
139        return self::$event_queue;
140    }
141
142    /**
143     * Record an event in Tracks.
144     *
145     * @param array $properties Properties to send with the event.
146     * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
147     */
148    private static function record_tracks_event( $properties = array() ) {
149        $pixel_url = Pixel_Builder::build_tracks_url( $properties );
150
151        if ( is_wp_error( $pixel_url ) ) {
152            return $pixel_url;
153        }
154
155        return self::record_pixel_url( $pixel_url );
156    }
157
158    /**
159     * Record a ClickHouse event.
160     *
161     * @param array $properties The event properties.
162     * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
163     */
164    private static function record_ch_event( $properties ) {
165        $pixel_url = Pixel_Builder::build_ch_url( $properties );
166
167        if ( is_wp_error( $pixel_url ) ) {
168            return $pixel_url;
169        }
170
171        return self::record_pixel_url( $pixel_url );
172    }
173
174    /**
175     * Record a pixel URL using batching.
176     *
177     * @param string $pixel_url The pixel URL to record.
178     * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
179     */
180    private static function record_pixel_url( $pixel_url ) {
181        if ( empty( $pixel_url ) ) {
182            return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
183        }
184
185        // Check if batching is supported.
186        $can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
187            || ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
188
189        if ( $can_batch ) {
190            // Queue the pixel and send on shutdown.
191            self::queue_pixel_for_batch( $pixel_url );
192        } else {
193            // Send immediately as batching is not supported.
194            Pixel_Builder::send_pixel( $pixel_url );
195        }
196
197        return true;
198    }
199
200    /**
201     * Queue a pixel URL for batch sending.
202     *
203     * @param string $pixel The pixel URL to queue.
204     */
205    private static function queue_pixel_for_batch( $pixel ) {
206        self::$pixel_batch_queue[] = $pixel;
207
208        // Register shutdown hook once.
209        if ( ! self::$shutdown_hook_registered ) {
210            add_action( 'shutdown', array( __CLASS__, 'send_batched_pixels' ), 20 );
211            self::$shutdown_hook_registered = true;
212        }
213    }
214
215    /**
216     * Send all queued pixels using batched non-blocking requests.
217     * This runs on the shutdown hook to batch all requests together.
218     *
219     * Uses Pixel_Builder for the actual sending via Requests library.
220     */
221    public static function send_batched_pixels() {
222        if ( empty( self::$pixel_batch_queue ) ) {
223            return;
224        }
225
226        // Delegate to Pixel_Builder for batched sending.
227        Pixel_Builder::send_pixels_batched( self::$pixel_batch_queue );
228
229        // Clear the queue.
230        self::$pixel_batch_queue = array();
231    }
232
233    /**
234     * Get the common properties for the event.
235     *
236     * @return array The common properties.
237     */
238    public static function get_common_properties() {
239        $blog_user_id    = self::get_blog_user_id();
240        $server_details  = self::get_server_details();
241        $blog_details    = self::get_blog_details();
242        $session_details = self::get_session_details();
243
244        $common_properties = array_merge(
245            array(
246                'session_id'     => $session_details['session_id'] ?? null,
247                'landing_page'   => $session_details['landing_page'] ?? null,
248                'is_engaged'     => $session_details['is_engaged'] ?? null,
249                'ui'             => $blog_user_id,
250                'blog_id'        => $blog_details['blog_id'] ?? null,
251                'store_id'       => $blog_details['store_id'] ?? null,
252                'url'            => $blog_details['url'] ?? null,
253                'woo_version'    => $blog_details['wc_version'] ?? null,
254                'wp_version'     => get_bloginfo( 'version' ),
255                'store_admin'    => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
256                'device'         => self::get_device_type(),
257                'store_currency' => $blog_details['store_currency'] ?? null,
258                'timezone'       => wp_timezone_string(),
259                'is_guest'       => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
260            ),
261            $server_details
262        );
263
264        return is_array( $common_properties ) ? $common_properties : array();
265    }
266
267    /**
268     * Get all properties for the event including filtered and identity properties.
269     *
270     * @param string $event_name Event name.
271     * @param array  $event_properties Event specific properties.
272     * @return array
273     */
274    public static function get_properties( $event_name, $event_properties ) {
275        $common_properties = self::get_common_properties();
276
277        /**
278         * Allow defining custom event properties in WooCommerce Analytics.
279         *
280         * @module woocommerce-analytics
281         *
282         * @since 12.5
283         *
284         * @param array $all_props Array of event props to be filtered.
285         */
286        $properties = apply_filters( 'jetpack_woocommerce_analytics_event_props', array_merge( $common_properties, $event_properties ), $event_name );
287
288        $required_properties = $event_name
289            ? array(
290                '_en' => $event_name,
291                '_ts' => Pixel_Builder::build_timestamp(),
292                '_ut' => 'anon',
293                '_ui' => self::get_visitor_id(),
294            )
295            : array();
296
297        $all_properties = array_merge( $properties, $required_properties );
298
299        // Convert array values to a comma-separated string and URL-encode them to ensure compatibility with JavaScript's encodeURIComponent() for pixel URL transmission.
300        foreach ( $all_properties as $key => $value ) {
301            if ( ! is_array( $value ) ) {
302                continue;
303            }
304
305            if ( empty( $value ) ) {
306                $all_properties[ $key ] = '';
307                continue;
308            }
309
310            $is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
311            if ( $is_indexed_array ) {
312                $value_string           = implode( ',', $value );
313                $all_properties[ $key ] = rawurlencode( $value_string );
314                continue;
315            }
316
317            // Serialize non-indexed arrays to JSON strings.
318            $all_properties[ $key ] = wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
319        }
320
321        return $all_properties;
322    }
323
324    /**
325     * Get the current user id.
326     *
327     * @return int The user ID, or 0 if not logged in.
328     */
329    private static function get_blog_user_id() {
330        // Ensure cookie constants are defined.
331        if ( ! defined( 'LOGGED_IN_COOKIE' ) ) {
332            if ( function_exists( 'wp_cookie_constants' ) ) {
333                wp_cookie_constants();
334            } else {
335                require_once ABSPATH . WPINC . '/default-constants.php';
336                wp_cookie_constants();
337            }
338        }
339
340        if ( function_exists( 'get_current_user_id' ) && get_current_user_id() ) {
341            return get_current_user_id();
342        }
343
344        // Manually validate the logged_in cookie
345        if ( ! function_exists( 'wp_validate_auth_cookie' ) ) {
346            require_once ABSPATH . WPINC . '/pluggable.php';
347        }
348
349        $user_id = wp_validate_auth_cookie( '', 'logged_in' );
350
351        return $user_id ? (int) $user_id : 0;
352    }
353
354    /**
355     * Gather details from the request to the server.
356     *
357     * This method is now standalone and doesn't rely on WC_Tracks parent class.
358     *
359     * @return array Server details.
360     */
361    public static function get_server_details() {
362        // Sanitization helper - use wc_clean if available, otherwise sanitize_text_field.
363        $clean = function_exists( 'wc_clean' ) ? 'wc_clean' : 'sanitize_text_field';
364
365        $data = array(
366            '_via_ua' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
367            '_via_ip' => self::get_user_ip_address(),
368            '_lg'     => isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ), 0, 5 ) : '',
369            '_dr'     => isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
370        );
371
372        // Build the document location URL.
373        $uri         = isset( $_SERVER['REQUEST_URI'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
374        $host        = isset( $_SERVER['HTTP_HOST'] ) ? $clean( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
375        $data['_dl'] = isset( $_SERVER['REQUEST_SCHEME'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_SCHEME'] ) ) . '://' . $host . $uri : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
376
377        // Add _via_ref (referrer) for backward compatibility.
378        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
379        $data['_via_ref'] = isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
380
381        return $data;
382    }
383
384    /**
385     * Get the blog details.
386     *
387     * This method is now standalone and doesn't rely on WC_Tracks parent class.
388     * It still works with WooCommerce when available for additional details.
389     *
390     * @return array The blog details.
391     */
392    public static function get_blog_details() {
393        // Try to get cached blog details.
394        $blog_details = get_transient( 'wc_analytics_blog_details' );
395
396        if ( false !== $blog_details ) {
397            return $blog_details;
398        }
399
400        // Get Jetpack blog ID if available.
401        $jetpack_blog_id = null;
402        if ( class_exists( 'Jetpack_Options' ) ) {
403            $jetpack_blog_id = \Jetpack_Options::get_option( 'id' );
404        }
405
406        // Get WooCommerce version if available.
407        // Check WC_VERSION constant first (most reliable), then fall back to option.
408        if ( defined( 'WC_VERSION' ) ) {
409            $wc_version = WC_VERSION;
410        } else {
411            $wc_version = get_option( 'woocommerce_version', '' );
412        }
413
414        // Get store ID from known option name.
415        $store_id = get_option( 'woocommerce_store_id', null );
416
417        // Get store currency - use WC function if available, otherwise fall back to option.
418        $store_currency = function_exists( 'get_woocommerce_currency' )
419        ? get_woocommerce_currency()
420        : get_option( 'woocommerce_currency', 'USD' );
421
422        $blog_details = array(
423            'url'            => home_url(),
424            'blog_lang'      => get_locale(),
425            'blog_id'        => $jetpack_blog_id,
426            'store_id'       => $store_id,
427            'wc_version'     => $wc_version,
428            'store_currency' => $store_currency,
429        );
430
431        // Cache for 1 day.
432        set_transient( 'wc_analytics_blog_details', $blog_details, DAY_IN_SECONDS );
433
434        return $blog_details;
435    }
436
437    /**
438     * Get the session details as an array
439     *
440     * @return array
441     */
442    private static function get_session_details() {
443        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is decoded and validated below. We don't need to sanitize the cookie value because we're not outputting it but decoding it as JSON. Sanitization might break the JSON.
444        $raw_cookie = isset( $_COOKIE['woocommerceanalytics_session'] ) ? wp_unslash( $_COOKIE['woocommerceanalytics_session'] ) : '';
445
446        if ( ! $raw_cookie ) {
447            return array();
448        }
449
450        $decoded = json_decode( rawurldecode( $raw_cookie ), true );
451        return is_array( $decoded ) ? $decoded : array();
452    }
453
454    /**
455     * Get the visitor id from the cookie or IP address (if proxy tracking is enabled).
456     *
457     * @return string|null
458     */
459    private static function get_visitor_id() {
460        // Return cached result if available.
461        if ( null !== self::$cached_visitor_id ) {
462            return self::$cached_visitor_id;
463        }
464
465        // Prefer tk_ai cookie if present.
466        if ( ! empty( $_COOKIE['tk_ai'] ) ) {
467            self::$cached_visitor_id = sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) );
468            return self::$cached_visitor_id;
469        }
470
471        // Fallback to IP-based visitor ID if proxy tracking is enabled.
472        if ( Features::is_proxy_tracking_enabled() ) {
473            self::$cached_visitor_id = self::get_ip_based_visitor_id();
474            return self::$cached_visitor_id;
475        }
476
477        // Generate a new anonId and try to save it in the browser's cookies.
478        // Note that base64-encoding an 18 character string generates a 24-character anon id.
479        $binary = '';
480        for ( $i = 0; $i < 18; ++$i ) {
481            $binary .= chr( wp_rand( 0, 255 ) );
482        }
483
484        self::$cached_visitor_id = base64_encode( $binary ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
485
486
487        if ( ! headers_sent()
488            && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST )
489            && ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
490        ) {
491            setcookie(
492                'tk_ai',
493                self::$cached_visitor_id,
494                array(
495                    'expires'  => time() + ( 365 * 24 * 60 * 60 ), // 1 year
496                    'path'     => '/',
497                    'domain'   => COOKIE_DOMAIN,
498                    'secure'   => is_ssl(),
499                    'httponly' => true,
500                    'samesite' => 'Strict',
501                )
502            );
503        }
504        return self::$cached_visitor_id;
505    }
506
507    /**
508     * Get the user's IP address.
509     *
510     * @return string The user's IP address. An empty string if no valid IP address is found.
511     */
512    private static function get_user_ip_address() {
513        // Return cached IP if available
514        if ( null !== self::$cached_ip ) {
515            return self::$cached_ip;
516        }
517
518        $ip_headers = array(
519            'HTTP_CF_CONNECTING_IP', // Cloudflare specific header.
520            'HTTP_X_FORWARDED_FOR',
521            'REMOTE_ADDR',
522            'HTTP_CLIENT_IP',
523        );
524
525        foreach ( $ip_headers as $header ) {
526            if ( isset( $_SERVER[ $header ] ) ) {
527                $ip_list = explode( ',', wp_unslash( $_SERVER[ $header ] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
528                foreach ( $ip_list as $ip_candidate ) {
529                    $ip_candidate = trim( $ip_candidate );
530                    if ( filter_var(
531                        $ip_candidate,
532                        FILTER_VALIDATE_IP,
533                        array( FILTER_FLAG_NO_RES_RANGE, FILTER_FLAG_IPV6 )
534                    ) ) {
535                        // Cache the resolved IP
536                        self::$cached_ip = $ip_candidate;
537                        return self::$cached_ip;
538                    }
539                }
540            }
541        }
542
543        // Cache empty result
544        self::$cached_ip = '';
545        return self::$cached_ip;
546    }
547
548    /**
549     * Get IP-based visitor ID for proxy tracking mode.
550     *
551     * @return string|null
552     */
553    private static function get_ip_based_visitor_id() {
554        $ip = self::get_user_ip_address();
555        if ( empty( $ip ) ) {
556            return null;
557        }
558
559        $salt       = self::get_daily_salt();
560        $url_parts  = wp_parse_url( home_url() );
561        $domain     = $url_parts['host'] ?? '';
562        $user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
563
564        // Create hash from: daily_salt + domain + ip + user_agent
565        $hash_input = $salt . $domain . $ip . $user_agent;
566
567        return substr( hash( 'sha256', $hash_input ), 0, 16 );
568    }
569
570    /**
571     * Get or generate daily salt for visitor ID hashing.
572     * Creates a new salt value each day (UTC) for privacy protection.
573     *
574     * @return string The daily salt.
575     */
576    private static function get_daily_salt() {
577        $today = gmdate( 'Y-m-d' ); // UTC date
578
579        $salt_data = get_option( self::DAILY_SALT_OPTION );
580
581        // Check if salt exists and is still valid for today
582        if (
583            is_array( $salt_data )
584            && isset( $salt_data['date'] )
585            && isset( $salt_data['salt'] )
586            && $salt_data['date'] === $today
587        ) {
588            return $salt_data['salt'];
589        }
590
591        // Generate new salt for today
592        $new_salt = wp_generate_password( 32, false );
593
594        // Store salt with date (no expiration time needed)
595        $salt_data = array(
596            'date' => $today,
597            'salt' => $new_salt,
598        );
599
600        update_option( self::DAILY_SALT_OPTION, $salt_data );
601        return $new_salt;
602    }
603
604    /**
605     * Get the device type for the current request.
606     *
607     * Uses Jetpack Device Detection to distinguish between mobile phones, tablets, and desktop devices.
608     *
609     * @return string 'mobile' for phones, 'tablet' for tablets, 'desktop' otherwise.
610     */
611    private static function get_device_type() {
612        if ( Device_Detection::is_phone() ) {
613            return 'mobile';
614        }
615
616        if ( Device_Detection::is_tablet() ) {
617            return 'tablet';
618        }
619
620        return 'desktop';
621    }
622}