diff --git a/src/components/EphemeralLoginHost.tsx b/src/components/EphemeralLoginHost.tsx index 076eff90b..bd85f6f5f 100644 --- a/src/components/EphemeralLoginHost.tsx +++ b/src/components/EphemeralLoginHost.tsx @@ -8,6 +8,7 @@ import { import {signInNative} from '#/state/session/oauth-native-sign-in' import {getOAuthScope} from '#/state/session/oauth-scopes' import {getWebOAuthClient} from '#/state/session/oauth-web-client' +import {getWebOAuthDisplay} from '#/state/session/oauth-web-display' import {Login} from '#/screens/Login' import {EphemeralLoginContext} from '#/screens/Login/EphemeralLoginContext' import {IS_WEB} from '#/env' @@ -30,7 +31,7 @@ export function EphemeralLoginHost() { const session = IS_WEB ? await getWebOAuthClient().signIn(identifier, { scope, - display: 'popup', + display: getWebOAuthDisplay(), }) : await signInNative(identifier, {scope}) await request.submit({ diff --git a/src/state/session/__tests__/oauth-appview-switch-test.ts b/src/state/session/__tests__/oauth-appview-switch-test.ts index 78dc38e8a..c325d6661 100644 --- a/src/state/session/__tests__/oauth-appview-switch-test.ts +++ b/src/state/session/__tests__/oauth-appview-switch-test.ts @@ -99,6 +99,24 @@ it('opens a popup immediately and leaves source routing intact until consent', a expect(values.size).toBe(0) }) +it('redirects installed PWAs and completes the app server switch on return', async () => { + window.matchMedia = jest.fn().mockReturnValue({matches: true}) + signIn.mockReturnValueOnce(new Promise(() => {})) + void startAppViewSwitch(account, selection, login) + expect(signIn).toHaveBeenCalledWith(account, { + scope: buildOAuthScope(), + display: 'page', + state: 'switch-state', + }) + expect(JSON.parse(values.get('oauth_appview_switch')!).mode).toBeUndefined() + expect(device.set).not.toHaveBeenCalled() + window.location.hash = '#state=switch-state&code=authorization-code' + initCallback.mockResolvedValueOnce({state: 'switch-state', session}) + await expect(completeWebOAuth(login)).resolves.toBe(true) + expect(device.set).toHaveBeenCalledWith(['customAppViewDid'], selection.did) + expect(login).toHaveBeenCalledWith(session) +}) + it.each(['access_denied', 'cancelled'])( 'preserves routing after %s', async reason => { diff --git a/src/state/session/__tests__/oauth-web-display-test.ts b/src/state/session/__tests__/oauth-web-display-test.ts new file mode 100644 index 000000000..f7a64c689 --- /dev/null +++ b/src/state/session/__tests__/oauth-web-display-test.ts @@ -0,0 +1,34 @@ +import {getWebOAuthDisplay} from '../oauth-web-display' + +const setItem = jest.fn() + +beforeEach(() => { + setItem.mockClear() +}) + +it.each(['standalone', 'fullscreen'])('uses a redirect in %s mode', mode => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + matchMedia: (query: string) => ({ + matches: query === `(display-mode: ${mode})`, + }), + location: {href: 'https://witchsky.app/settings'}, + sessionStorage: {setItem}, + }, + }) + expect(getWebOAuthDisplay()).toBe('page') + expect(setItem).toHaveBeenCalledWith( + 'oauth_return_url', + 'https://witchsky.app/settings', + ) +}) + +it('keeps popups in a regular browser tab', () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: {matchMedia: () => ({matches: false})}, + }) + expect(getWebOAuthDisplay()).toBe('popup') + expect(setItem).not.toHaveBeenCalled() +}) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index da6d9404e..aa87f17b8 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -30,6 +30,7 @@ import {pickExpiryRescueCandidate} from './expiry-rescue' import {signInNative} from './oauth-native-sign-in' import {getOAuthScope} from './oauth-scopes' import {getWebOAuthClient} from './oauth-web-client' +import {getWebOAuthDisplay} from './oauth-web-display' import {type Action, getInitialState, reducer, type State} from './reducer' import { type ActiveSessionBundle, @@ -488,7 +489,10 @@ export function Provider({children}: PropsWithChildren<{}>) { if (options?.directOAuth) { const scope = options.scope ?? getOAuthScope() const authorization = IS_WEB - ? getWebOAuthClient().signIn(account.did, {scope, display: 'popup'}) + ? getWebOAuthClient().signIn(account.did, { + scope, + display: getWebOAuthDisplay(), + }) : signInNative(account.did, {scope}) return authorization.then(oauthSession => authenticate( diff --git a/src/state/session/oauth-appview-switch.ts b/src/state/session/oauth-appview-switch.ts index 97934ac70..0af230d43 100644 --- a/src/state/session/oauth-appview-switch.ts +++ b/src/state/session/oauth-appview-switch.ts @@ -6,6 +6,7 @@ import {restoreOAuthSession} from './oauth-client-adapter' import {buildOAuthScope, hasOAuthAppViewScope} from './oauth-config' import {getOAuthAudiences} from './oauth-scopes' import {getWebOAuthClient} from './oauth-web-client' +import {getWebOAuthDisplay} from './oauth-web-display' type Selection = {did?: string; url?: string} type PendingSwitch = { @@ -28,7 +29,7 @@ function applySelection(selection: Selection) { device.set(['customAppViewUrl'], selection.url) } -/** Keep the source document active until the new grant is ready. */ +/** Use a popup when the source document can remain active during consent. */ export async function startAppViewSwitch( account: string, selection: Selection, @@ -41,8 +42,9 @@ export async function startAppViewSwitch( audiences.appview = selection.did ? `${selection.did}#bsky_appview` : 'did:web:api.bsky.app#bsky_appview' + const display = getWebOAuthDisplay() const pending: PendingSwitch = { - mode: 'popup', + mode: display === 'popup' ? 'popup' : undefined, account, state: crypto.randomUUID(), selection, @@ -60,7 +62,8 @@ export async function startAppViewSwitch( try { const session = await getWebOAuthClient(audiences).signIn(account, { scope: buildOAuthScope(audiences.appview, audiences.chat), - display: 'popup', + display, + ...(display === 'page' ? {state: pending.state} : {}), }) if (session.did !== account) { throw new Error('Unexpected OAuth account or AppView switch') diff --git a/src/state/session/oauth-web-display.ts b/src/state/session/oauth-web-display.ts new file mode 100644 index 000000000..2e637426d --- /dev/null +++ b/src/state/session/oauth-web-display.ts @@ -0,0 +1,18 @@ +import {saveOAuthReturnUrl} from './oauth-web-return-url' + +/** + * Opening a browser tab can suspend an installed PWA before the SDK has + * prepared the authorization URL, leaving that tab stuck at about:blank. + */ +export function getWebOAuthDisplay(): 'page' | 'popup' { + const standalone = + typeof window !== 'undefined' && + (window.matchMedia?.('(display-mode: standalone)').matches || + window.matchMedia?.('(display-mode: fullscreen)').matches || + (window.navigator as Navigator & {standalone?: boolean})?.standalone) + if (standalone) { + saveOAuthReturnUrl() + return 'page' + } + return 'popup' +}