diff --git a/resources/js/components/guest-join-sheet.tsx b/resources/js/components/guest-join-sheet.tsx
index f06e51327..1cf3de47a 100644
--- a/resources/js/components/guest-join-sheet.tsx
+++ b/resources/js/components/guest-join-sheet.tsx
@@ -1,7 +1,9 @@
-import { GitCommitVertical, UsersRound, X } from 'lucide-react';
-import { useSyncExternalStore } from 'react';
+import { Compass, GitCommitVertical, UsersRound, X } from 'lucide-react';
+import { useState, useSyncExternalStore } from 'react';
import { GitHubIcon } from '@/components/github-icon';
import { Button } from '@/components/ui/button';
+import type { InAppBrowser } from '@/lib/in-app-browser';
+import { inAppBrowser, phoneBrowserUrl } from '@/lib/in-app-browser';
import { login } from '@/routes';
const DISMISSED = 'devnull:join-sheet-dismissed';
@@ -35,7 +37,9 @@ function dismiss(): void {
/**
* On a phone, guests don't see the aside that tells desktop visitors what /dev/null is, so this sheet does, with the
* button where the thumb is. It stays out of the server render and opens once the page has loaded, unless the guest
- * closed it earlier in this browser session. Signing in leaves for GitHub, so the button is a plain link.
+ * closed it earlier in this browser session. Signing in leaves for GitHub, so the button is a plain link. In an app's
+ * browser, it first offers to open the page in the phone's browser, where the guest may already be signed in to
+ * GitHub.
*/
export function GuestJoinSheet() {
const isHidden = useSyncExternalStore(subscribe, isDismissed, () => true);
@@ -44,6 +48,9 @@ export function GuestJoinSheet() {
return null;
}
+ // The sheet never renders on the server, so the user agent is there to read...
+ const browser = inAppBrowser(navigator.userAgent);
+
return (
+ You’re in {browser.app ? `${browser.app}’s` : 'an app’s'}{' '} + browser, which isn’t signed in to GitHub.{' '} + {hasFailed + ? `Nothing opened? Use the app’s menu (⋯) to open this page in ${name}.` + : `In ${name}, you may already be.`} +
+ + > + ); +} diff --git a/resources/js/lib/in-app-browser.ts b/resources/js/lib/in-app-browser.ts new file mode 100644 index 000000000..931d96709 --- /dev/null +++ b/resources/js/lib/in-app-browser.ts @@ -0,0 +1,55 @@ +export type InAppBrowser = { + /** The app showing the page, when its user agent tells. */ + app: string | null; + platform: 'ios' | 'android'; +}; + +const APPS: [RegExp, string][] = [ + [/Twitter/i, 'X'], + [/Instagram/, 'Instagram'], + [/FBAN|FBAV|FB_IAB/, 'Facebook'], + [/LinkedInApp/, 'LinkedIn'], + [/Barcelona/, 'Threads'], + [/BytedanceWebview|musical_ly/, 'TikTok'], + [/Snapchat/, 'Snapchat'], +]; + +/** + * The browser an app opens links in on a phone, rather than the phone's own browser. It is signed in to nothing, so + * signing in with GitHub there means typing a password and a 2FA code, without the passkey or saved password Safari or + * Chrome would offer. Known apps name themselves in the user agent; other web views drop Safari's token on iOS, or + * add `wv` on Android. Apps that open links in Safari's own view look like Safari and aren't told apart. + */ +export function inAppBrowser(userAgent: string): InAppBrowser | null { + const platform = /iPhone|iPad|iPod/.test(userAgent) + ? 'ios' + : /Android/.test(userAgent) + ? 'android' + : null; + + if (platform === null) { + return null; + } + + const app = APPS.find(([pattern]) => pattern.test(userAgent))?.[1] ?? null; + const isWebView = + app !== null || + (platform === 'ios' + ? !/Safari\//.test(userAgent) + : /; wv\)/.test(userAgent)); + + return isWebView ? { app, platform } : null; +} + +/** + * A link that opens the page in the phone's browser: Safari's own scheme on iOS, which Apple doesn't document but + * works from web views since iOS 17, or an intent for the default browser on Android. + */ +export function phoneBrowserUrl( + url: URL, + platform: InAppBrowser['platform'], +): string { + return platform === 'ios' + ? `x-safari-${url.href}` + : `intent://${url.host}${url.pathname}${url.search}${url.hash}#Intent;scheme=https;end`; +} diff --git a/tests/Unit/InAppBrowser.test.ts b/tests/Unit/InAppBrowser.test.ts new file mode 100644 index 000000000..50b6ada75 --- /dev/null +++ b/tests/Unit/InAppBrowser.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { inAppBrowser, phoneBrowserUrl } from '../../resources/js/lib/in-app-browser.ts'; + +const SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1'; +const IOS_WEB_VIEW = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; +const CHROME_ANDROID = + 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36'; +const ANDROID_WEB_VIEW = + 'Mozilla/5.0 (Linux; Android 15; Pixel 9; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/141.0.0.0 Mobile Safari/537.36'; + +await test('tells the app whose browser shows the page, when it names itself', () => { + assert.deepEqual(inAppBrowser(`${IOS_WEB_VIEW} Twitter for iPhone`), { app: 'X', platform: 'ios' }); + assert.deepEqual(inAppBrowser(`${IOS_WEB_VIEW} Instagram 400.0.0.0`), { app: 'Instagram', platform: 'ios' }); + assert.deepEqual(inAppBrowser(`${ANDROID_WEB_VIEW} [FB_IAB/FB4A;FBAV/500.0.0.0;]`), { app: 'Facebook', platform: 'android' }); + assert.deepEqual(inAppBrowser(`${SAFARI} LinkedInApp/9.30`), { app: 'LinkedIn', platform: 'ios' }); +}); + +await test('tells web views of apps that do not name themselves', () => { + assert.deepEqual(inAppBrowser(IOS_WEB_VIEW), { app: null, platform: 'ios' }); + assert.deepEqual(inAppBrowser(ANDROID_WEB_VIEW), { app: null, platform: 'android' }); +}); + +await test('leaves the phone browsers and computers alone', () => { + for (const userAgent of [ + SAFARI, + CHROME_ANDROID, + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/141.0.0.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/144.0 Mobile/15E148 Safari/605.1.15', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15', + ]) { + assert.equal(inAppBrowser(userAgent), null); + } +}); + +await test('opens the page in Safari on iOS and in the default browser on Android', () => { + const url = new URL('https://dev-null.sh/@mnapoli/log/abc?tab=replies#reply-1'); + + assert.equal(phoneBrowserUrl(url, 'ios'), 'x-safari-https://dev-null.sh/@mnapoli/log/abc?tab=replies#reply-1'); + assert.equal( + phoneBrowserUrl(url, 'android'), + 'intent://dev-null.sh/@mnapoli/log/abc?tab=replies#reply-1#Intent;scheme=https;end', + ); +});