diff --git a/.gitignore b/.gitignore index 660fc15e4..35aa09ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor composer.lock /phpunit.xml +/.phpunit.cache .phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ff13c9b..787a82746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Release Notes -## [Unreleased](https://github.com/laravel/jetstream/compare/v4.0.4...4.x) +## [Unreleased](https://github.com/laravel/jetstream/compare/v4.0.5...4.x) + +## [v4.0.5](https://github.com/laravel/jetstream/compare/v4.0.4...v4.0.5) - 2023-10-27 + +- Replacing left/right classes with start/end to support RTL by [@AbdullahObaid](https://github.com/AbdullahObaid) in https://github.com/laravel/jetstream/pull/1392 ## [v4.0.4](https://github.com/laravel/jetstream/compare/v4.0.3...v4.0.4) - 2023-10-18 diff --git a/composer.json b/composer.json index eb790c288..d7394b23d 100644 --- a/composer.json +++ b/composer.json @@ -18,8 +18,8 @@ "ext-json": "*", "illuminate/console": "^10.17", "illuminate/support": "^10.17", - "jenssegers/agent": "^2.6", - "laravel/fortify": "^1.15" + "laravel/fortify": "^1.15", + "mobiledetect/mobiledetectlib": "^4.8" }, "require-dev": { "inertiajs/inertia-laravel": "^0.6.5", @@ -28,7 +28,7 @@ "mockery/mockery": "^1.0", "orchestra/testbench": "^8.11", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^10.1" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2183cc0e4..40374652e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,18 +1,16 @@ - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.4/phpunit.xsd" + cacheDirectory=".phpunit.cache" + backupStaticProperties="false"> - ./tests/ + ./tests/ diff --git a/src/Agent.php b/src/Agent.php new file mode 100644 index 000000000..19b409582 --- /dev/null +++ b/src/Agent.php @@ -0,0 +1,173 @@ + + */ + protected static $additionalOperatingSystems = [ + 'Windows' => 'Windows', + 'Windows NT' => 'Windows NT', + 'OS X' => 'Mac OS X', + 'Debian' => 'Debian', + 'Ubuntu' => 'Ubuntu', + 'Macintosh' => 'PPC', + 'OpenBSD' => 'OpenBSD', + 'Linux' => 'Linux', + 'ChromeOS' => 'CrOS', + ]; + + /** + * List of additional browsers. + * + * @var array + */ + protected static $additionalBrowsers = [ + 'Opera Mini' => 'Opera Mini', + 'Opera' => 'Opera|OPR', + 'Edge' => 'Edge|Edg', + 'Coc Coc' => 'coc_coc_browser', + 'UCBrowser' => 'UCBrowser', + 'Vivaldi' => 'Vivaldi', + 'Chrome' => 'Chrome', + 'Firefox' => 'Firefox', + 'Safari' => 'Safari', + 'IE' => 'MSIE|IEMobile|MSIEMobile|Trident/[.0-9]+', + 'Netscape' => 'Netscape', + 'Mozilla' => 'Mozilla', + 'WeChat' => 'MicroMessenger', + ]; + + /** + * Get the platform name from the User Agent. + * + * @return string|null + */ + public function platform() + { + return $this->retrieveUsingCacheOrResolve('jetstream.platform', function () { + return $this->findDetectionRulesAgainstUserAgent( + $this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems) + ); + }); + } + + /** + * Get the browser name from the User Agent. + * + * @return string|null + */ + public function browser() + { + return $this->retrieveUsingCacheOrResolve('jetstream.browser', function () { + return $this->findDetectionRulesAgainstUserAgent( + $this->mergeRules(static::$additionalBrowsers, MobileDetect::getBrowsers()) + ); + }); + } + + /** + * Determine if the device is a desktop computer. + * + * @return bool + */ + public function isDesktop() + { + return $this->retrieveUsingCacheOrResolve('jetstream.desktop', function () { + // Check specifically for cloudfront headers if the useragent === 'Amazon CloudFront' + if ( + $this->getUserAgent() === static::$cloudFrontUA + && $this->getHttpHeader('HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER') === 'true' + ) { + return true; + } + + return ! $this->isMobile() && ! $this->isTablet(); + }); + } + + /** + * Match a detection rule and return the matched key. + * + * @return string|null + */ + protected function findDetectionRulesAgainstUserAgent(array $rules) + { + $userAgent = $this->getUserAgent(); + + foreach ($rules as $key => $regex) { + if (empty($regex)) { + continue; + } + + if ($this->match($regex, $userAgent)) { + return $key ?: reset($this->matchesArray); + } + } + + return null; + } + + /** + * Retrieve from the given key from the cache or resolve the value. + * + * @param string $key + * @param \Closure():mixed $callback + * @return mixed + * + * @throws \Detection\Exception\MobileDetectException + */ + protected function retrieveUsingCacheOrResolve(string $key, Closure $callback) + { + try { + $cacheKey = $this->createCacheKey($key); + + if (! is_null($cacheItem = $this->cache->get($cacheKey))) { + return $cacheItem->get(); + } + + return tap(call_user_func($callback), function ($result) use ($cacheKey) { + $this->cache->set($cacheKey, $result); + }); + } catch (CacheException $e) { + throw new MobileDetectException("Cache problem in for {$key}: {$e->getMessage()}"); + } + } + + /** + * Merge multiple rules into one array. + * + * @param array $all + * @return array + */ + protected function mergeRules(...$all) + { + $merged = []; + + foreach ($all as $rules) { + foreach ($rules as $key => $value) { + if (empty($merged[$key])) { + $merged[$key] = $value; + } elseif (is_array($merged[$key])) { + $merged[$key][] = $value; + } else { + $merged[$key] .= '|'.$value; + } + } + } + + return $merged; + } +} diff --git a/src/HasProfilePhoto.php b/src/HasProfilePhoto.php index 2ad98039f..080089312 100644 --- a/src/HasProfilePhoto.php +++ b/src/HasProfilePhoto.php @@ -59,7 +59,7 @@ public function deleteProfilePhoto() */ public function profilePhotoUrl(): Attribute { - return Attribute::get(function () { + return Attribute::get(function (): string { return $this->profile_photo_path ? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path) : $this->defaultProfilePhotoUrl(); diff --git a/src/Http/Controllers/Inertia/UserProfileController.php b/src/Http/Controllers/Inertia/UserProfileController.php index 22464f5d2..c5cd67071 100644 --- a/src/Http/Controllers/Inertia/UserProfileController.php +++ b/src/Http/Controllers/Inertia/UserProfileController.php @@ -6,8 +6,8 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; -use Jenssegers\Agent\Agent; use Laravel\Fortify\Features; +use Laravel\Jetstream\Agent; use Laravel\Jetstream\Jetstream; class UserProfileController extends Controller @@ -67,12 +67,10 @@ public function sessions(Request $request) * Create a new agent instance from the given session. * * @param mixed $session - * @return \Jenssegers\Agent\Agent + * @return \Laravel\Jetstream\Agent */ protected function createAgent($session) { - return tap(new Agent, function ($agent) use ($session) { - $agent->setUserAgent($session->user_agent); - }); + return tap(new Agent(), fn ($agent) => $agent->setUserAgent($session->user_agent)); } } diff --git a/src/Http/Livewire/LogoutOtherBrowserSessionsForm.php b/src/Http/Livewire/LogoutOtherBrowserSessionsForm.php index 01d755f1e..e70fcd094 100644 --- a/src/Http/Livewire/LogoutOtherBrowserSessionsForm.php +++ b/src/Http/Livewire/LogoutOtherBrowserSessionsForm.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; -use Jenssegers\Agent\Agent; +use Laravel\Jetstream\Agent; use Livewire\Component; class LogoutOtherBrowserSessionsForm extends Component @@ -121,13 +121,11 @@ public function getSessionsProperty() * Create a new agent instance from the given session. * * @param mixed $session - * @return \Jenssegers\Agent\Agent + * @return \Laravel\Jetstream\Agent */ protected function createAgent($session) { - return tap(new Agent, function ($agent) use ($session) { - $agent->setUserAgent($session->user_agent); - }); + return tap(new Agent(), fn ($agent) => $agent->setUserAgent($session->user_agent)); } /** diff --git a/tests/AgentTest.php b/tests/AgentTest.php new file mode 100644 index 000000000..0cdea53d3 --- /dev/null +++ b/tests/AgentTest.php @@ -0,0 +1,98 @@ +setUserAgent($userAgent); + + $this->assertEquals($platform, $agent->platform()); + } + + public static function operatingSystemsDataProvider() + { + yield ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586', 'Windows']; + yield ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.13+ (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2', 'OS X']; + yield ['Mozilla/5.0 (iPad; CPU OS 5_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko ) Version/5.1 Mobile/9B176 Safari/7534.48.3', 'iOS']; + yield ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:24.0) Gecko/20100101 Firefox/24.0', 'Ubuntu']; + yield ['Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+', 'BlackBerryOS']; + yield ['Mozilla/5.0 (Linux; U; Android 2.2; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', 'AndroidOS']; + yield ['Mozilla/5.0 (X11; CrOS x86_64 6680.78.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.102 Safari/537.36', 'ChromeOS']; + yield ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36', 'Windows']; + } + + /** + * @param string $userAgent + * @param string $browser + * @return void + */ + #[DataProvider('browsersDataProvider')] + public function testBrowsers($userAgent, $browser) + { + $agent = new Agent(); + $agent->setUserAgent($userAgent); + + $this->assertEquals($browser, $agent->browser()); + } + + public static function browsersDataProvider() + { + yield ['Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko', 'IE']; + yield ['Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5355d Safari/8536.25', 'Safari']; + yield ['Mozilla/5.0 (Windows; U; Win 9x 4.90; SG; rv:1.9.2.4) Gecko/20101104 Netscape/9.1.0285', 'Netscape']; + yield ['Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:25.0) Gecko/20100101 Firefox/25.0', 'Firefox']; + yield ['Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/32.0.1667.0 Safari/537.36', 'Chrome']; + yield ['Mozilla/5.0 (Windows; U; Windows NT 6.1; rv:2.2) Gecko/20110201', 'Mozilla']; + yield ['Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14', 'Opera']; + yield ['Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36 OPR/27.0.1689.76', 'Opera']; + yield ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12', 'Edge']; + yield ['Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25', 'Safari']; + yield ['Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 Vivaldi/1.2.490.43', 'Vivaldi']; + yield ['Mozilla/5.0 (Linux; U; Android 4.0.4; en-US; LT28h Build/6.1.E.3.7) AppleWebKit/534.31 (KHTML, like Gecko) UCBrowser/9.2.2.323 U3/0.8.0 Mobile Safari/534.31', 'UCBrowser']; + yield ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063', 'Edge']; + yield ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.29 Safari/537.36 Edg/79.0.309.18', 'Edge']; + yield ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) coc_coc_browser/86.0.180 Chrome/80.0.3987.180 Safari/537.36', 'Coc Coc']; + } + + /** + * @param string $userAgent + * @param bool $expected + * @return void + */ + #[DataProvider('devicesDataProvider')] + public function testDesktopDevices($userAgent, $expected) + { + $agent = new Agent(); + $agent->setUserAgent($userAgent); + + $this->assertSame($expected, $agent->isDesktop()); + } + + public static function devicesDataProvider() + { + // User Agent, Is Desktop? + yield ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11) AppleWebKit/601.1.56 (KHTML, like Gecko) Version/9.0 Safari/601.1.56', true]; + yield ['Mozilla/5.0 (iPhone; U; ru; CPU iPhone OS 4_2_1 like Mac OS X; ru) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148a Safari/6533.18.5', false]; + yield ['Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5355d Safari/8536.25', false]; + yield ['Mozilla/5.0 (Linux; U; Android 2.3.4; fr-fr; HTC Desire Build/GRJ22) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', false]; + yield ['Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+', false]; + yield ['Mozilla/5.0 (Linux; U; Android 2.2; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', false]; + yield ['Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; ASUS Transformer Pad TF300T Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30', false]; + } +}