@symbiote-native/web-browser 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/build/angular/index.d.ts +1 -0
- package/build/angular/index.js +4 -0
- package/build/core/index.d.ts +3 -0
- package/build/core/index.js +2 -0
- package/build/core/native-module.d.ts +12 -0
- package/build/core/native-module.js +3 -0
- package/build/core/types.d.ts +193 -0
- package/build/core/types.js +37 -0
- package/build/core/web-browser.d.ts +83 -0
- package/build/core/web-browser.js +256 -0
- package/build/react/index.d.ts +1 -0
- package/build/react/index.js +7 -0
- package/build/vue/index.d.ts +1 -0
- package/build/vue/index.js +4 -0
- package/build-ngc/angular/index.d.ts +1 -0
- package/build-ngc/angular/index.js +5 -0
- package/build-ngc/angular/index.js.map +1 -0
- package/build-ngc/core/index.d.ts +3 -0
- package/build-ngc/core/index.js +3 -0
- package/build-ngc/core/index.js.map +1 -0
- package/build-ngc/core/native-module.d.ts +12 -0
- package/build-ngc/core/native-module.js +4 -0
- package/build-ngc/core/native-module.js.map +1 -0
- package/build-ngc/core/types.d.ts +193 -0
- package/build-ngc/core/types.js +38 -0
- package/build-ngc/core/types.js.map +1 -0
- package/build-ngc/core/web-browser.d.ts +83 -0
- package/build-ngc/core/web-browser.js +257 -0
- package/build-ngc/core/web-browser.js.map +1 -0
- package/native-link.json +12 -0
- package/package.json +107 -0
- package/src/angular/index.ts +4 -0
- package/src/core/index.ts +25 -0
- package/src/core/native-module.ts +43 -0
- package/src/core/types.ts +208 -0
- package/src/core/web-browser.test.ts +295 -0
- package/src/core/web-browser.ts +316 -0
- package/src/react/index.ts +7 -0
- package/src/vue/index.ts +4 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const FAKE_NATIVE_WEB_BROWSER = {
|
|
4
|
+
openBrowserAsync: vi.fn(async () => ({ type: 'opened' })),
|
|
5
|
+
dismissBrowser: vi.fn(async () => ({ type: 'dismiss' })),
|
|
6
|
+
openAuthSessionAsync: vi.fn(async () => ({ type: 'success', url: 'myapp://callback' })),
|
|
7
|
+
dismissAuthSession: vi.fn(() => undefined),
|
|
8
|
+
warmUpAsync: vi.fn(async () => ({ servicePackage: 'com.android.chrome' })),
|
|
9
|
+
coolDownAsync: vi.fn(async () => ({ servicePackage: 'com.android.chrome' })),
|
|
10
|
+
mayInitWithUrlAsync: vi.fn(async () => ({ servicePackage: 'com.android.chrome' })),
|
|
11
|
+
getCustomTabsSupportingBrowsersAsync: vi.fn(async () => ({
|
|
12
|
+
defaultBrowserPackage: 'com.android.chrome',
|
|
13
|
+
preferredBrowserPackage: 'com.android.chrome',
|
|
14
|
+
browserPackages: ['com.android.chrome'],
|
|
15
|
+
servicePackages: ['com.android.chrome'],
|
|
16
|
+
})),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const fakePlatform = { OS: 'ios' as 'ios' | 'android' };
|
|
20
|
+
|
|
21
|
+
// The real ExpoWebBrowser native module only exists on device — resolving it via
|
|
22
|
+
// requireNativeModule() at import time would throw in this headless run, so the module-lookup file
|
|
23
|
+
// is faked in place of expo-modules-core's runtime resolution, the same pattern
|
|
24
|
+
// packages/secure-store/src/core/secure-store.test.ts uses.
|
|
25
|
+
vi.mock('./native-module', () => ({
|
|
26
|
+
expoWebBrowser: FAKE_NATIVE_WEB_BROWSER,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// expo-modules-core's real entry transitively imports 'react-native', whose Flow-typed source
|
|
30
|
+
// Vitest's Oxc transform can't parse — so only the members used as values are faked.
|
|
31
|
+
vi.mock('expo-modules-core', () => ({
|
|
32
|
+
Platform: fakePlatform,
|
|
33
|
+
UnavailabilityError: class UnavailabilityError extends Error {
|
|
34
|
+
constructor(moduleName: string, propertyName: string) {
|
|
35
|
+
super(`${propertyName} is not available on ${moduleName}`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const appStateListeners = new Set<(state: string) => void>();
|
|
41
|
+
const urlListeners = new Set<(event: { url: string }) => void>();
|
|
42
|
+
|
|
43
|
+
// Same reason as the expo-modules-core fake above: react-native's own source can't be parsed here.
|
|
44
|
+
// AppState and Linking are faked as real, drivable event sources because the Android auth-session
|
|
45
|
+
// polyfill's whole behavior is the race between them.
|
|
46
|
+
vi.mock('react-native', () => ({
|
|
47
|
+
AppState: {
|
|
48
|
+
currentState: 'active',
|
|
49
|
+
addEventListener: (_type: string, handler: (state: string) => void) => {
|
|
50
|
+
appStateListeners.add(handler);
|
|
51
|
+
return { remove: () => appStateListeners.delete(handler) };
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
Linking: {
|
|
55
|
+
addEventListener: (_type: string, handler: (event: { url: string }) => void) => {
|
|
56
|
+
urlListeners.add(handler);
|
|
57
|
+
return { remove: () => urlListeners.delete(handler) };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
processColor: (value?: string) => (value === undefined ? undefined : `processed:${value}`),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const {
|
|
64
|
+
getCustomTabsSupportingBrowsersAsync,
|
|
65
|
+
warmUpAsync,
|
|
66
|
+
mayInitWithUrlAsync,
|
|
67
|
+
coolDownAsync,
|
|
68
|
+
openBrowserAsync,
|
|
69
|
+
dismissBrowser,
|
|
70
|
+
openAuthSessionAsync,
|
|
71
|
+
dismissAuthSession,
|
|
72
|
+
} = await import('./web-browser');
|
|
73
|
+
|
|
74
|
+
function emitAppState(state: string): void {
|
|
75
|
+
for (const listener of [...appStateListeners]) listener(state);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function emitUrl(url: string): void {
|
|
79
|
+
for (const listener of [...urlListeners]) listener({ url });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Lets every already-queued microtask and the polyfill's own promise chain settle.
|
|
83
|
+
function flush(): Promise<void> {
|
|
84
|
+
return new Promise(resolve => setTimeout(resolve, 0));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
fakePlatform.OS = 'ios';
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('openBrowserAsync', () => {
|
|
93
|
+
it('passes the url and options through to the native module', async () => {
|
|
94
|
+
await expect(openBrowserAsync('https://example.com', { showTitle: true })).resolves.toEqual({
|
|
95
|
+
type: 'opened',
|
|
96
|
+
});
|
|
97
|
+
expect(FAKE_NATIVE_WEB_BROWSER.openBrowserAsync).toHaveBeenCalledWith('https://example.com', {
|
|
98
|
+
showTitle: true,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// A color reaches native as a platform integer, never as the CSS string the caller wrote.
|
|
103
|
+
it('runs the three color options through processColor', async () => {
|
|
104
|
+
await openBrowserAsync('https://example.com', {
|
|
105
|
+
toolbarColor: '#ffffff',
|
|
106
|
+
secondaryToolbarColor: 'blue',
|
|
107
|
+
controlsColor: 'red',
|
|
108
|
+
});
|
|
109
|
+
expect(FAKE_NATIVE_WEB_BROWSER.openBrowserAsync).toHaveBeenCalledWith('https://example.com', {
|
|
110
|
+
toolbarColor: 'processed:#ffffff',
|
|
111
|
+
secondaryToolbarColor: 'processed:blue',
|
|
112
|
+
controlsColor: 'processed:red',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('throws an UnavailabilityError-shaped error when the native method is absent', async () => {
|
|
117
|
+
const { openBrowserAsync: native } = FAKE_NATIVE_WEB_BROWSER;
|
|
118
|
+
// @ts-expect-error -- simulating a platform where the native module has no such method
|
|
119
|
+
FAKE_NATIVE_WEB_BROWSER.openBrowserAsync = undefined;
|
|
120
|
+
|
|
121
|
+
await expect(openBrowserAsync('https://example.com')).rejects.toThrow(
|
|
122
|
+
'openBrowserAsync is not available on expo-web-browser',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
FAKE_NATIVE_WEB_BROWSER.openBrowserAsync = native;
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('dismissBrowser', () => {
|
|
130
|
+
it('delegates to the native module', async () => {
|
|
131
|
+
await expect(dismissBrowser()).resolves.toEqual({ type: 'dismiss' });
|
|
132
|
+
expect(FAKE_NATIVE_WEB_BROWSER.dismissBrowser).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Android's Custom Tabs offer no way to close a tab programmatically, so the native module
|
|
136
|
+
// registers no dismissBrowser there at all.
|
|
137
|
+
it('throws where dismissing is not available', () => {
|
|
138
|
+
const { dismissBrowser: native } = FAKE_NATIVE_WEB_BROWSER;
|
|
139
|
+
// @ts-expect-error -- simulating Android, where the native module has no such method
|
|
140
|
+
FAKE_NATIVE_WEB_BROWSER.dismissBrowser = undefined;
|
|
141
|
+
|
|
142
|
+
expect(() => dismissBrowser()).toThrow('dismissBrowser is not available on expo-web-browser');
|
|
143
|
+
|
|
144
|
+
FAKE_NATIVE_WEB_BROWSER.dismissBrowser = native;
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('the Custom Tabs service functions', () => {
|
|
149
|
+
it('delegate to the native module on Android', async () => {
|
|
150
|
+
fakePlatform.OS = 'android';
|
|
151
|
+
|
|
152
|
+
await expect(warmUpAsync('com.android.chrome')).resolves.toEqual({
|
|
153
|
+
servicePackage: 'com.android.chrome',
|
|
154
|
+
});
|
|
155
|
+
expect(FAKE_NATIVE_WEB_BROWSER.warmUpAsync).toHaveBeenCalledWith('com.android.chrome');
|
|
156
|
+
|
|
157
|
+
await coolDownAsync('com.android.chrome');
|
|
158
|
+
expect(FAKE_NATIVE_WEB_BROWSER.coolDownAsync).toHaveBeenCalledWith('com.android.chrome');
|
|
159
|
+
|
|
160
|
+
await mayInitWithUrlAsync('https://example.com', 'com.android.chrome');
|
|
161
|
+
expect(FAKE_NATIVE_WEB_BROWSER.mayInitWithUrlAsync).toHaveBeenCalledWith(
|
|
162
|
+
'https://example.com',
|
|
163
|
+
'com.android.chrome',
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await expect(getCustomTabsSupportingBrowsersAsync()).resolves.toMatchObject({
|
|
167
|
+
browserPackages: ['com.android.chrome'],
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('resolve empty off Android without touching the native module', async () => {
|
|
172
|
+
await expect(warmUpAsync()).resolves.toEqual({});
|
|
173
|
+
await expect(coolDownAsync()).resolves.toEqual({});
|
|
174
|
+
await expect(mayInitWithUrlAsync('https://example.com')).resolves.toEqual({});
|
|
175
|
+
expect(FAKE_NATIVE_WEB_BROWSER.warmUpAsync).not.toHaveBeenCalled();
|
|
176
|
+
expect(FAKE_NATIVE_WEB_BROWSER.coolDownAsync).not.toHaveBeenCalled();
|
|
177
|
+
expect(FAKE_NATIVE_WEB_BROWSER.mayInitWithUrlAsync).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('report the browser list as unavailable when the native module has no such method', async () => {
|
|
181
|
+
fakePlatform.OS = 'android';
|
|
182
|
+
const { getCustomTabsSupportingBrowsersAsync: native } = FAKE_NATIVE_WEB_BROWSER;
|
|
183
|
+
// @ts-expect-error -- simulating iOS, which registers the stub without the `Async` suffix
|
|
184
|
+
FAKE_NATIVE_WEB_BROWSER.getCustomTabsSupportingBrowsersAsync = undefined;
|
|
185
|
+
|
|
186
|
+
await expect(getCustomTabsSupportingBrowsersAsync()).rejects.toThrow(
|
|
187
|
+
'getCustomTabsSupportingBrowsersAsync is not available on expo-web-browser',
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
FAKE_NATIVE_WEB_BROWSER.getCustomTabsSupportingBrowsersAsync = native;
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('openAuthSessionAsync on a platform with a native auth session', () => {
|
|
195
|
+
it('hands the url, redirect url and processed options to the native module', async () => {
|
|
196
|
+
await expect(
|
|
197
|
+
openAuthSessionAsync('https://login.example', 'myapp://callback', {
|
|
198
|
+
preferEphemeralSession: true,
|
|
199
|
+
controlsColor: 'red',
|
|
200
|
+
}),
|
|
201
|
+
).resolves.toEqual({ type: 'success', url: 'myapp://callback' });
|
|
202
|
+
|
|
203
|
+
expect(FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync).toHaveBeenCalledWith(
|
|
204
|
+
'https://login.example',
|
|
205
|
+
'myapp://callback',
|
|
206
|
+
{ preferEphemeralSession: true, controlsColor: 'processed:red' },
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('throws an UnavailabilityError-shaped error when the native method is absent', async () => {
|
|
211
|
+
const { openAuthSessionAsync: native } = FAKE_NATIVE_WEB_BROWSER;
|
|
212
|
+
// @ts-expect-error -- simulating a platform where the native module has no such method
|
|
213
|
+
FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = undefined;
|
|
214
|
+
|
|
215
|
+
await expect(openAuthSessionAsync('https://login.example')).rejects.toThrow(
|
|
216
|
+
'openAuthSessionAsync is not available on expo-web-browser',
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = native;
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Android has no native auth session: a Custom Tab is opened and the result is whichever comes
|
|
224
|
+
// first, the deep link back into the app or the app returning to the foreground.
|
|
225
|
+
describe('openAuthSessionAsync on Android', () => {
|
|
226
|
+
it('resolves with the redirect once the deep link arrives', async () => {
|
|
227
|
+
fakePlatform.OS = 'android';
|
|
228
|
+
// Android's native module has neither of these; without removing them the polyfill would never
|
|
229
|
+
// be reached and the `finally` would dismiss a browser that cannot be dismissed.
|
|
230
|
+
const { openAuthSessionAsync: nativeAuth, dismissBrowser: nativeDismiss } =
|
|
231
|
+
FAKE_NATIVE_WEB_BROWSER;
|
|
232
|
+
// @ts-expect-error -- simulating Android's native surface
|
|
233
|
+
FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = undefined;
|
|
234
|
+
// @ts-expect-error -- simulating Android's native surface
|
|
235
|
+
FAKE_NATIVE_WEB_BROWSER.dismissBrowser = undefined;
|
|
236
|
+
|
|
237
|
+
const session = openAuthSessionAsync('https://login.example', 'myapp://callback');
|
|
238
|
+
await flush();
|
|
239
|
+
emitUrl('myapp://callback?code=abc');
|
|
240
|
+
|
|
241
|
+
await expect(session).resolves.toEqual({
|
|
242
|
+
type: 'success',
|
|
243
|
+
url: 'myapp://callback?code=abc',
|
|
244
|
+
});
|
|
245
|
+
expect(FAKE_NATIVE_WEB_BROWSER.openBrowserAsync).toHaveBeenCalledWith(
|
|
246
|
+
'https://login.example',
|
|
247
|
+
{},
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Release the browser-wait promise the race left pending, so the next session can start.
|
|
251
|
+
emitAppState('active');
|
|
252
|
+
await flush();
|
|
253
|
+
|
|
254
|
+
FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = nativeAuth;
|
|
255
|
+
FAKE_NATIVE_WEB_BROWSER.dismissBrowser = nativeDismiss;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('resolves as dismissed when the app returns to the foreground without a redirect', async () => {
|
|
259
|
+
fakePlatform.OS = 'android';
|
|
260
|
+
const { openAuthSessionAsync: nativeAuth, dismissBrowser: nativeDismiss } =
|
|
261
|
+
FAKE_NATIVE_WEB_BROWSER;
|
|
262
|
+
// @ts-expect-error -- simulating Android's native surface
|
|
263
|
+
FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = undefined;
|
|
264
|
+
// @ts-expect-error -- simulating Android's native surface
|
|
265
|
+
FAKE_NATIVE_WEB_BROWSER.dismissBrowser = undefined;
|
|
266
|
+
|
|
267
|
+
const session = openAuthSessionAsync('https://login.example', 'myapp://callback');
|
|
268
|
+
await flush();
|
|
269
|
+
emitAppState('active');
|
|
270
|
+
|
|
271
|
+
await expect(session).resolves.toEqual({ type: 'dismiss' });
|
|
272
|
+
// The redirect listener is torn down again, so a second session is allowed to start.
|
|
273
|
+
expect(urlListeners.size).toBe(0);
|
|
274
|
+
|
|
275
|
+
FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = nativeAuth;
|
|
276
|
+
FAKE_NATIVE_WEB_BROWSER.dismissBrowser = nativeDismiss;
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('dismissAuthSession', () => {
|
|
281
|
+
it('dismisses the native auth session where one exists', () => {
|
|
282
|
+
dismissAuthSession();
|
|
283
|
+
expect(FAKE_NATIVE_WEB_BROWSER.dismissAuthSession).toHaveBeenCalled();
|
|
284
|
+
expect(FAKE_NATIVE_WEB_BROWSER.dismissBrowser).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('falls back to dismissing the browser where there is no native auth session', () => {
|
|
288
|
+
fakePlatform.OS = 'android';
|
|
289
|
+
|
|
290
|
+
dismissAuthSession();
|
|
291
|
+
|
|
292
|
+
expect(FAKE_NATIVE_WEB_BROWSER.dismissBrowser).toHaveBeenCalled();
|
|
293
|
+
expect(FAKE_NATIVE_WEB_BROWSER.dismissAuthSession).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Hand-ported from .vendors/expo/packages/expo-web-browser/src/WebBrowser.ts (sdk-57). Two
|
|
2
|
+
// deliberate departures from upstream, both because SymbioteNative has no web target:
|
|
3
|
+
// `maybeCompleteAuthSession` is not ported (it exists only to close a `window.open` popup; on a
|
|
4
|
+
// native platform it can do nothing but return `{ type: 'failed' }`), and the web-only open
|
|
5
|
+
// options are gone from IWebBrowserOpenOptions.
|
|
6
|
+
import { Platform, UnavailabilityError } from 'expo-modules-core';
|
|
7
|
+
import type { AppStateStatus, EmitterSubscription } from 'react-native';
|
|
8
|
+
import { AppState, Linking, processColor } from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { expoWebBrowser } from './native-module';
|
|
11
|
+
import {
|
|
12
|
+
WebBrowserResultType,
|
|
13
|
+
type IAuthSessionOpenOptions,
|
|
14
|
+
type IProcessedOpenOptions,
|
|
15
|
+
type IRedirectEvent,
|
|
16
|
+
type IWebBrowserAuthSessionResult,
|
|
17
|
+
type IWebBrowserCoolDownResult,
|
|
18
|
+
type IWebBrowserCustomTabsResults,
|
|
19
|
+
type IWebBrowserDismissResult,
|
|
20
|
+
type IWebBrowserMayInitWithUrlResult,
|
|
21
|
+
type IWebBrowserOpenOptions,
|
|
22
|
+
type IWebBrowserRedirectResult,
|
|
23
|
+
type IWebBrowserResult,
|
|
24
|
+
type IWebBrowserWarmUpResult,
|
|
25
|
+
} from './types';
|
|
26
|
+
|
|
27
|
+
const NATIVE_MODULE_NAME = 'expo-web-browser';
|
|
28
|
+
|
|
29
|
+
const EMPTY_CUSTOM_TABS_PACKAGES: IWebBrowserCustomTabsResults = {
|
|
30
|
+
defaultBrowserPackage: undefined,
|
|
31
|
+
preferredBrowserPackage: undefined,
|
|
32
|
+
browserPackages: [],
|
|
33
|
+
servicePackages: [],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The packages that support Custom Tabs, the Custom Tabs service, and the user's chosen and
|
|
38
|
+
* preferred browser. Only as reliable as `PackageManager.getResolvingActivities` underneath — a
|
|
39
|
+
* browser can be missing from `browserPackages` once another one is set as default.
|
|
40
|
+
*
|
|
41
|
+
* @platform android
|
|
42
|
+
*/
|
|
43
|
+
export async function getCustomTabsSupportingBrowsersAsync(): Promise<IWebBrowserCustomTabsResults> {
|
|
44
|
+
// iOS registers its no-op stub as `getCustomTabsSupportingBrowsers`, without the `Async`
|
|
45
|
+
// suffix, so this guard — kept in upstream's order deliberately — fires there before the
|
|
46
|
+
// non-Android early return below can be reached.
|
|
47
|
+
if (!expoWebBrowser.getCustomTabsSupportingBrowsersAsync) {
|
|
48
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'getCustomTabsSupportingBrowsersAsync');
|
|
49
|
+
}
|
|
50
|
+
if (Platform.OS !== 'android') {
|
|
51
|
+
return EMPTY_CUSTOM_TABS_PACKAGES;
|
|
52
|
+
}
|
|
53
|
+
return expoWebBrowser.getCustomTabsSupportingBrowsersAsync();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calls `warmUp` on the [`CustomTabsClient`](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsClient#warmup(long))
|
|
58
|
+
* for the given package.
|
|
59
|
+
*
|
|
60
|
+
* @param browserPackage Browser to warm up. Defaults to the preferred one.
|
|
61
|
+
* @platform android
|
|
62
|
+
*/
|
|
63
|
+
export async function warmUpAsync(browserPackage?: string): Promise<IWebBrowserWarmUpResult> {
|
|
64
|
+
if (!expoWebBrowser.warmUpAsync) {
|
|
65
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'warmUpAsync');
|
|
66
|
+
}
|
|
67
|
+
if (Platform.OS !== 'android') {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
return expoWebBrowser.warmUpAsync(browserPackage);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Starts a [`CustomTabsSession`](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsSession#mayLaunchUrl(android.net.Uri,android.os.Bundle,java.util.List%3Candroid.os.Bundle%3E))
|
|
75
|
+
* if needed and calls its `mayLaunchUrl`, so the browser can prefetch the page.
|
|
76
|
+
*
|
|
77
|
+
* @param url The page most likely to be opened first.
|
|
78
|
+
* @param browserPackage Browser to inform. Defaults to the preferred one.
|
|
79
|
+
* @platform android
|
|
80
|
+
*/
|
|
81
|
+
export async function mayInitWithUrlAsync(
|
|
82
|
+
url: string,
|
|
83
|
+
browserPackage?: string,
|
|
84
|
+
): Promise<IWebBrowserMayInitWithUrlResult> {
|
|
85
|
+
if (!expoWebBrowser.mayInitWithUrlAsync) {
|
|
86
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'mayInitWithUrlAsync');
|
|
87
|
+
}
|
|
88
|
+
if (Platform.OS !== 'android') {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
return expoWebBrowser.mayInitWithUrlAsync(url, browserPackage);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Drops every binding `warmUpAsync` and `mayInitWithUrlAsync` created. Call it once the bindings
|
|
96
|
+
* are no longer needed to avoid leaking them — though they are also released when the app is
|
|
97
|
+
* destroyed, which is often good enough.
|
|
98
|
+
*
|
|
99
|
+
* @param browserPackage Browser to cool down. Defaults to the preferred one.
|
|
100
|
+
* @returns The cooled service, or an empty object when there was no connection to dismiss.
|
|
101
|
+
* @platform android
|
|
102
|
+
*/
|
|
103
|
+
export async function coolDownAsync(browserPackage?: string): Promise<IWebBrowserCoolDownResult> {
|
|
104
|
+
if (!expoWebBrowser.coolDownAsync) {
|
|
105
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'coolDownAsync');
|
|
106
|
+
}
|
|
107
|
+
if (Platform.OS !== 'android') {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
return expoWebBrowser.coolDownAsync(browserPackage);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Opens the url in a modal [`SFSafariViewController`](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller)
|
|
115
|
+
* on iOS and a [Custom Tab](https://developer.chrome.com/docs/android/custom-tabs) on Android. The
|
|
116
|
+
* modal Safari does not share cookies with the system Safari — for a login flow that needs them,
|
|
117
|
+
* use `openAuthSessionAsync`.
|
|
118
|
+
*
|
|
119
|
+
* @returns Android resolves with `{ type: 'opened' }` as soon as the tab launches, without waiting
|
|
120
|
+
* for the user to close it. iOS resolves with `{ type: 'cancel' }` when the user dismissed the
|
|
121
|
+
* browser and `{ type: 'dismiss' }` when `dismissBrowser` closed it.
|
|
122
|
+
*/
|
|
123
|
+
export async function openBrowserAsync(
|
|
124
|
+
url: string,
|
|
125
|
+
browserParams: IWebBrowserOpenOptions = {},
|
|
126
|
+
): Promise<IWebBrowserResult> {
|
|
127
|
+
if (!expoWebBrowser.openBrowserAsync) {
|
|
128
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'openBrowserAsync');
|
|
129
|
+
}
|
|
130
|
+
return expoWebBrowser.openBrowserAsync(url, processOptions(browserParams));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Dismisses the presented browser. Throws where dismissing isn't available — Android's Custom Tabs
|
|
135
|
+
* offer no way to close a tab programmatically, so there the user has to press the tab's own close
|
|
136
|
+
* button.
|
|
137
|
+
*
|
|
138
|
+
* @platform ios
|
|
139
|
+
*/
|
|
140
|
+
export function dismissBrowser(): Promise<IWebBrowserDismissResult> {
|
|
141
|
+
if (!expoWebBrowser.dismissBrowser) {
|
|
142
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'dismissBrowser');
|
|
143
|
+
}
|
|
144
|
+
return expoWebBrowser.dismissBrowser();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Opens a login page and resolves once the authentication provider redirects back to the app.
|
|
149
|
+
*
|
|
150
|
+
* On iOS this is `ASWebAuthenticationSession`, which asks the user whether the app may
|
|
151
|
+
* authenticate with the given url; the redirect URI registered with the authentication server has
|
|
152
|
+
* to use the app's own scheme (`demo://…`, not `https://…`). Adding a `Linking` listener of your
|
|
153
|
+
* own is unnecessary there and can have side effects.
|
|
154
|
+
*
|
|
155
|
+
* Android has no equivalent native API, so it is polyfilled: a Custom Tab plus `AppState` and
|
|
156
|
+
* `Linking`, racing "the deep link came back" against "the app returned to the foreground". Only
|
|
157
|
+
* one such session can be open at a time.
|
|
158
|
+
*
|
|
159
|
+
* @param url The login page to open.
|
|
160
|
+
* @param redirectUrl The url that deep-links back into the app. Without it the session can only
|
|
161
|
+
* end by the user closing the browser.
|
|
162
|
+
* @returns `{ type: 'success', url }` with the redirect url on a completed login,
|
|
163
|
+
* `{ type: 'cancel' }` when the user declined or closed the browser, and `{ type: 'dismiss' }` when
|
|
164
|
+
* `dismissBrowser` closed it.
|
|
165
|
+
*/
|
|
166
|
+
export async function openAuthSessionAsync(
|
|
167
|
+
url: string,
|
|
168
|
+
redirectUrl?: string | null,
|
|
169
|
+
options: IAuthSessionOpenOptions = {},
|
|
170
|
+
): Promise<IWebBrowserAuthSessionResult> {
|
|
171
|
+
if (!isAuthSessionNativelySupported()) {
|
|
172
|
+
return openAuthSessionPolyfillAsync(url, redirectUrl, options);
|
|
173
|
+
}
|
|
174
|
+
if (!expoWebBrowser.openAuthSessionAsync) {
|
|
175
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'openAuthSessionAsync');
|
|
176
|
+
}
|
|
177
|
+
return expoWebBrowser.openAuthSessionAsync(url, redirectUrl, processOptions(options));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Dismisses the current authentication session. Falls back to dismissing the plain browser where
|
|
182
|
+
* there is no native auth session, which on Android throws for the same reason `dismissBrowser`
|
|
183
|
+
* does.
|
|
184
|
+
*
|
|
185
|
+
* @platform ios
|
|
186
|
+
*/
|
|
187
|
+
export function dismissAuthSession(): void {
|
|
188
|
+
if (isAuthSessionNativelySupported()) {
|
|
189
|
+
if (!expoWebBrowser.dismissAuthSession) {
|
|
190
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'dismissAuthSession');
|
|
191
|
+
}
|
|
192
|
+
expoWebBrowser.dismissAuthSession();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!expoWebBrowser.dismissBrowser) {
|
|
196
|
+
throw new UnavailabilityError(NATIVE_MODULE_NAME, 'dismissBrowser');
|
|
197
|
+
}
|
|
198
|
+
void expoWebBrowser.dismissBrowser();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function processOptions(options: IAuthSessionOpenOptions): IProcessedOpenOptions {
|
|
202
|
+
return {
|
|
203
|
+
...options,
|
|
204
|
+
controlsColor: processColor(options.controlsColor),
|
|
205
|
+
toolbarColor: processColor(options.toolbarColor),
|
|
206
|
+
secondaryToolbarColor: processColor(options.secondaryToolbarColor),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* Android polyfill for the ASWebAuthenticationSession flow */
|
|
211
|
+
|
|
212
|
+
function isAuthSessionNativelySupported(): boolean {
|
|
213
|
+
return Platform.OS !== 'android';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let redirectSubscription: EmitterSubscription | null = null;
|
|
217
|
+
|
|
218
|
+
// `openBrowserAsync` on Android resolves the moment the Custom Tab launches, not when the user
|
|
219
|
+
// closes it, so the polyfill has to watch AppState instead. This holds the `resolve` of the
|
|
220
|
+
// promise that waits for the app to come back to the foreground.
|
|
221
|
+
let onWebBrowserCloseAndroid: (() => void) | null = null;
|
|
222
|
+
|
|
223
|
+
// A null initial `AppState.currentState` means the first `change` event is the bridge reporting
|
|
224
|
+
// the state it captured, not a real transition — that one has to be ignored, or the session would
|
|
225
|
+
// end the instant it started. See https://reactnative.dev/docs/appstate#basic-usage.
|
|
226
|
+
let isAppStateAvailable: boolean = AppState.currentState !== null;
|
|
227
|
+
|
|
228
|
+
function onAppStateChangeAndroid(state: AppStateStatus): void {
|
|
229
|
+
if (!isAppStateAvailable) {
|
|
230
|
+
isAppStateAvailable = true;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (state === 'active' && onWebBrowserCloseAndroid) {
|
|
234
|
+
onWebBrowserCloseAndroid();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function openBrowserAndWaitAndroidAsync(
|
|
239
|
+
startUrl: string,
|
|
240
|
+
browserParams: IWebBrowserOpenOptions = {},
|
|
241
|
+
): Promise<IWebBrowserResult> {
|
|
242
|
+
const appStateChangedToActive = new Promise<void>(resolve => {
|
|
243
|
+
onWebBrowserCloseAndroid = resolve;
|
|
244
|
+
});
|
|
245
|
+
const stateChangeSubscription = AppState.addEventListener('change', onAppStateChangeAndroid);
|
|
246
|
+
|
|
247
|
+
let launched: IWebBrowserResult;
|
|
248
|
+
try {
|
|
249
|
+
launched = await openBrowserAsync(startUrl, browserParams);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
stateChangeSubscription.remove();
|
|
252
|
+
onWebBrowserCloseAndroid = null;
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let result: IWebBrowserResult = { type: WebBrowserResultType.CANCEL };
|
|
257
|
+
if (launched.type === WebBrowserResultType.OPENED) {
|
|
258
|
+
await appStateChangedToActive;
|
|
259
|
+
result = { type: WebBrowserResultType.DISMISS };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
stateChangeSubscription.remove();
|
|
263
|
+
onWebBrowserCloseAndroid = null;
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function openAuthSessionPolyfillAsync(
|
|
268
|
+
startUrl: string,
|
|
269
|
+
returnUrl?: string | null,
|
|
270
|
+
browserParams: IWebBrowserOpenOptions = {},
|
|
271
|
+
): Promise<IWebBrowserAuthSessionResult> {
|
|
272
|
+
if (redirectSubscription) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
"The WebBrowser's auth session is in an invalid state with a redirect handler set when it should not be",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (onWebBrowserCloseAndroid) {
|
|
278
|
+
throw new Error('WebBrowser is already open, only one can be open at a time');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
return await Promise.race([
|
|
283
|
+
openBrowserAndWaitAndroidAsync(startUrl, browserParams),
|
|
284
|
+
waitForRedirectAsync(returnUrl),
|
|
285
|
+
]);
|
|
286
|
+
} finally {
|
|
287
|
+
// Only reachable on a platform that can dismiss a browser at all — Android users have to
|
|
288
|
+
// close the Custom Tab themselves.
|
|
289
|
+
if (expoWebBrowser.dismissBrowser) {
|
|
290
|
+
void expoWebBrowser.dismissBrowser();
|
|
291
|
+
}
|
|
292
|
+
stopWaitingForRedirect();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function stopWaitingForRedirect(): void {
|
|
297
|
+
if (!redirectSubscription) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
'The WebBrowser auth session is in an invalid state with no redirect handler when one should be set',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
redirectSubscription.remove();
|
|
303
|
+
redirectSubscription = null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function waitForRedirectAsync(returnUrl?: string | null): Promise<IWebBrowserRedirectResult> {
|
|
307
|
+
// Deliberately never resolves when `returnUrl` is nullish: the browser promise it is raced
|
|
308
|
+
// against is then the only thing that can settle the session.
|
|
309
|
+
return new Promise(resolve => {
|
|
310
|
+
redirectSubscription = Linking.addEventListener('url', (event: IRedirectEvent) => {
|
|
311
|
+
if (returnUrl && event.url.startsWith(returnUrl)) {
|
|
312
|
+
resolve({ url: event.url, type: 'success' });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @symbiote-native/web-browser/react: the React entry over the framework-agnostic core.
|
|
2
|
+
// Every export is a stateless free function. The one piece of live state — the Android
|
|
3
|
+
// auth-session polyfill's redirect subscription — belongs to a single in-flight promise inside the
|
|
4
|
+
// core and never surfaces as something a caller subscribes to or tears down, so there is nothing
|
|
5
|
+
// for a hook to wrap. Hence a plain re-export, the same shape packages/secure-store's React entry
|
|
6
|
+
// has for the same reason.
|
|
7
|
+
export * from '../core';
|
package/src/vue/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// @symbiote-native/web-browser/vue: the Vue entry over the framework-agnostic core.
|
|
2
|
+
// Nothing here holds per-instance state or hands back a subscription, so there is no lifecycle for
|
|
3
|
+
// a composable to own — see the React entry for the full reasoning.
|
|
4
|
+
export * from '../core';
|