1import compareUrls from 'compare-urls';
2import { CodedError, Platform } from 'expo-modules-core';
3import { AppState, Dimensions } from 'react-native';
4import { WebBrowserResultType, } from './WebBrowser.types';
5const POPUP_WIDTH = 500;
6const POPUP_HEIGHT = 650;
7let popupWindow = null;
8const listenerMap = new Map();
9const getHandle = () => 'ExpoWebBrowserRedirectHandle';
10const getOriginUrlHandle = (hash) => `ExpoWebBrowser_OriginUrl_${hash}`;
11const getRedirectUrlHandle = (hash) => `ExpoWebBrowser_RedirectUrl_${hash}`;
12function dismissPopup() {
13    if (!popupWindow) {
14        return;
15    }
16    popupWindow.close();
17    if (listenerMap.has(popupWindow)) {
18        const { listener, appStateSubscription, interval } = listenerMap.get(popupWindow);
19        clearInterval(interval);
20        window.removeEventListener('message', listener);
21        appStateSubscription.remove();
22        listenerMap.delete(popupWindow);
23        const handle = window.localStorage.getItem(getHandle());
24        if (handle) {
25            window.localStorage.removeItem(getHandle());
26            window.localStorage.removeItem(getOriginUrlHandle(handle));
27            window.localStorage.removeItem(getRedirectUrlHandle(handle));
28        }
29        popupWindow = null;
30    }
31}
32export default {
33    get name() {
34        return 'ExpoWebBrowser';
35    },
36    async openBrowserAsync(url, browserParams = {}) {
37        if (!Platform.isDOMAvailable)
38            return { type: WebBrowserResultType.CANCEL };
39        const { windowName = '_blank', windowFeatures } = browserParams;
40        const features = getPopupFeaturesString(windowFeatures);
41        window.open(url, windowName, features);
42        return { type: WebBrowserResultType.OPENED };
43    },
44    dismissAuthSession() {
45        if (!Platform.isDOMAvailable)
46            return;
47        dismissPopup();
48    },
49    maybeCompleteAuthSession({ skipRedirectCheck }) {
50        if (!Platform.isDOMAvailable) {
51            return {
52                type: 'failed',
53                message: 'Cannot use expo-web-browser in a non-browser environment',
54            };
55        }
56        const handle = window.localStorage.getItem(getHandle());
57        if (!handle) {
58            return { type: 'failed', message: 'No auth session is currently in progress' };
59        }
60        const url = window.location.href;
61        if (skipRedirectCheck !== true) {
62            const redirectUrl = window.localStorage.getItem(getRedirectUrlHandle(handle));
63            // Compare the original redirect url against the current url with it's query params removed.
64            const currentUrl = window.location.origin + window.location.pathname;
65            if (!compareUrls(redirectUrl, currentUrl)) {
66                return {
67                    type: 'failed',
68                    message: `Current URL "${currentUrl}" and original redirect URL "${redirectUrl}" do not match.`,
69                };
70            }
71        }
72        // Save the link for app state listener
73        window.localStorage.setItem(getOriginUrlHandle(handle), url);
74        // Get the window that created the current popup
75        const parent = window.opener ?? window.parent;
76        if (!parent) {
77            throw new CodedError('ERR_WEB_BROWSER_REDIRECT', `The window cannot complete the redirect request because the invoking window doesn't have a reference to it's parent. This can happen if the parent window was reloaded.`);
78        }
79        // Send the URL back to the opening window.
80        parent.postMessage({ url, expoSender: handle }, parent.location.toString());
81        return { type: 'success', message: `Attempting to complete auth` };
82        // Maybe set timer to throw an error if the window is still open after attempting to complete.
83    },
84    // This method should be invoked from user input.
85    async openAuthSessionAsync(url, redirectUrl, openOptions) {
86        if (!Platform.isDOMAvailable)
87            return { type: WebBrowserResultType.CANCEL };
88        redirectUrl = redirectUrl ?? getRedirectUrlFromUrlOrGenerate(url);
89        if (popupWindow == null || popupWindow?.closed) {
90            const features = getPopupFeaturesString(openOptions?.windowFeatures);
91            popupWindow = window.open(url, openOptions?.windowName, features);
92            if (popupWindow) {
93                try {
94                    popupWindow.focus();
95                }
96                catch { }
97            }
98            else {
99                throw new CodedError('ERR_WEB_BROWSER_BLOCKED', 'Popup window was blocked by the browser or failed to open. This can happen in mobile browsers when the window.open() method was invoked too long after a user input was fired.');
100            }
101        }
102        const state = await getStateFromUrlOrGenerateAsync(url);
103        // Save handle for session
104        window.localStorage.setItem(getHandle(), state);
105        // Save redirect Url for further verification
106        window.localStorage.setItem(getRedirectUrlHandle(state), redirectUrl);
107        return new Promise(async (resolve) => {
108            // Create a listener for messages sent from the popup
109            const listener = (event) => {
110                if (!event.isTrusted)
111                    return;
112                // Ensure we trust the sender.
113                if (event.origin !== window.location.origin) {
114                    return;
115                }
116                const { data } = event;
117                // Use a crypto hash to invalid message.
118                const handle = window.localStorage.getItem(getHandle());
119                // Ensure the sender is also from expo-web-browser
120                if (data.expoSender === handle) {
121                    dismissPopup();
122                    resolve({ type: 'success', url: data.url });
123                }
124            };
125            // Add a listener for receiving messages from the popup.
126            window.addEventListener('message', listener, false);
127            // Create an app state listener as a fallback to the popup listener
128            const appStateListener = (state) => {
129                if (state !== 'active') {
130                    return;
131                }
132                const handle = window.localStorage.getItem(getHandle());
133                if (handle) {
134                    const url = window.localStorage.getItem(getOriginUrlHandle(handle));
135                    if (url) {
136                        dismissPopup();
137                        resolve({ type: 'success', url });
138                    }
139                }
140            };
141            const appStateSubscription = AppState.addEventListener('change', appStateListener);
142            // Check if the window has been closed every second.
143            const interval = setInterval(() => {
144                if (popupWindow?.closed) {
145                    if (resolve)
146                        resolve({ type: WebBrowserResultType.DISMISS });
147                    clearInterval(interval);
148                    dismissPopup();
149                }
150            }, 1000);
151            // Store the listener and interval for clean up.
152            listenerMap.set(popupWindow, {
153                listener,
154                interval,
155                appStateSubscription,
156            });
157        });
158    },
159};
160// Crypto
161function isCryptoAvailable() {
162    if (!Platform.isDOMAvailable)
163        return false;
164    return !!window?.crypto;
165}
166function isSubtleCryptoAvailable() {
167    if (!isCryptoAvailable())
168        return false;
169    return !!window.crypto.subtle;
170}
171async function getStateFromUrlOrGenerateAsync(inputUrl) {
172    const url = new URL(inputUrl);
173    if (url.searchParams.has('state') && typeof url.searchParams.get('state') === 'string') {
174        // Ensure we reuse the auth state if it's passed in.
175        return url.searchParams.get('state');
176    }
177    // Generate a crypto state for verifying the return popup.
178    return await generateStateAsync();
179}
180function getRedirectUrlFromUrlOrGenerate(inputUrl) {
181    const url = new URL(inputUrl);
182    if (url.searchParams.has('redirect_uri') &&
183        typeof url.searchParams.get('redirect_uri') === 'string') {
184        // Ensure we reuse the redirect_uri if it's passed in the input url.
185        return url.searchParams.get('redirect_uri');
186    }
187    // Emulate how native uses Constants.linkingUrl
188    return location.origin + location.pathname;
189}
190const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
191async function generateStateAsync() {
192    if (!isSubtleCryptoAvailable()) {
193        throw new CodedError('ERR_WEB_BROWSER_CRYPTO', `The current environment doesn't support crypto. Ensure you are running from a secure origin (https).`);
194    }
195    const encoder = new TextEncoder();
196    const data = generateRandom(10);
197    const buffer = encoder.encode(data);
198    const hashedData = await crypto.subtle.digest('SHA-256', buffer);
199    const state = btoa(String.fromCharCode(...new Uint8Array(hashedData)));
200    return state;
201}
202function generateRandom(size) {
203    let arr = new Uint8Array(size);
204    if (arr.byteLength !== arr.length) {
205        arr = new Uint8Array(arr.buffer);
206    }
207    const array = new Uint8Array(arr.length);
208    if (isCryptoAvailable()) {
209        window.crypto.getRandomValues(array);
210    }
211    else {
212        for (let i = 0; i < size; i += 1) {
213            array[i] = (Math.random() * CHARSET.length) | 0;
214        }
215    }
216    return bufferToString(array);
217}
218function bufferToString(buffer) {
219    const state = [];
220    for (let i = 0; i < buffer.byteLength; i += 1) {
221        const index = buffer[i] % CHARSET.length;
222        state.push(CHARSET[index]);
223    }
224    return state.join('');
225}
226// Window Features
227// Ensure feature string is an object
228function normalizePopupFeaturesString(options) {
229    let windowFeatures = {};
230    // This should be avoided because it adds extra time to the popup command.
231    if (typeof options === 'string') {
232        // Convert string of `key=value,foo=bar` into an object
233        const windowFeaturePairs = options.split(',');
234        for (const pair of windowFeaturePairs) {
235            const [key, value] = pair.trim().split('=');
236            if (key && value) {
237                windowFeatures[key] = value;
238            }
239        }
240    }
241    else if (options) {
242        windowFeatures = options;
243    }
244    return windowFeatures;
245}
246// Apply default values to the input feature set
247function getPopupFeaturesString(options) {
248    const windowFeatures = normalizePopupFeaturesString(options);
249    const width = windowFeatures.width ?? POPUP_WIDTH;
250    const height = windowFeatures.height ?? POPUP_HEIGHT;
251    const dimensions = Dimensions.get('screen');
252    const top = windowFeatures.top ?? Math.max(0, (dimensions.height - height) * 0.5);
253    const left = windowFeatures.left ?? Math.max(0, (dimensions.width - width) * 0.5);
254    // Create a reasonable popup
255    // https://developer.mozilla.org/en-US/docs/Web/API/Window/open#Window_features
256    return featureObjectToString({
257        ...windowFeatures,
258        // Toolbar buttons (Back, Forward, Reload, Stop buttons).
259        toolbar: windowFeatures.toolbar ?? 'no',
260        menubar: windowFeatures.menubar ?? 'no',
261        // Shows the location bar or the address bar.
262        location: windowFeatures.location ?? 'yes',
263        resizable: windowFeatures.resizable ?? 'yes',
264        // If this feature is on, then the new secondary window has a status bar.
265        status: windowFeatures.status ?? 'no',
266        scrollbars: windowFeatures.scrollbars ?? 'yes',
267        top,
268        left,
269        width,
270        height,
271    });
272}
273export function featureObjectToString(features) {
274    return Object.keys(features).reduce((prev, current) => {
275        let value = features[current];
276        if (typeof value === 'boolean') {
277            value = value ? 'yes' : 'no';
278        }
279        if (current && value) {
280            if (prev)
281                prev += ',';
282            return `${prev}${current}=${value}`;
283        }
284        return prev;
285    }, '');
286}
287//# sourceMappingURL=ExpoWebBrowser.web.js.map