@oxyhq/services 8.6.0 → 8.6.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/lib/commonjs/ui/components/OxyProvider.js +2 -0
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/SignInModal.js +4 -3
- package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +53 -1
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/screens/OxyAuthScreen.js +3 -3
- package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/appName.js +62 -0
- package/lib/commonjs/ui/utils/appName.js.map +1 -0
- package/lib/module/ui/components/OxyProvider.js +2 -0
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/SignInModal.js +4 -3
- package/lib/module/ui/components/SignInModal.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +53 -1
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/screens/OxyAuthScreen.js +3 -3
- package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/module/ui/utils/appName.js +59 -0
- package/lib/module/ui/utils/appName.js.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +12 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/appName.d.ts +22 -0
- package/lib/typescript/commonjs/ui/utils/appName.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +12 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/appName.d.ts +22 -0
- package/lib/typescript/module/ui/utils/appName.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/components/SignInModal.tsx +3 -3
- package/src/ui/context/OxyContext.tsx +68 -0
- package/src/ui/screens/OxyAuthScreen.tsx +3 -3
- package/src/ui/types/navigation.ts +8 -0
- package/src/ui/utils/__tests__/appName.test.ts +52 -0
- package/src/ui/utils/appName.ts +62 -0
|
@@ -103,6 +103,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
103
103
|
children,
|
|
104
104
|
onAuthStateChange,
|
|
105
105
|
storageKeyPrefix,
|
|
106
|
+
appName,
|
|
106
107
|
baseURL,
|
|
107
108
|
authWebUrl,
|
|
108
109
|
authRedirectUri,
|
|
@@ -296,6 +297,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
296
297
|
authWebUrl={authWebUrl}
|
|
297
298
|
authRedirectUri={authRedirectUri}
|
|
298
299
|
storageKeyPrefix={storageKeyPrefix}
|
|
300
|
+
appName={appName}
|
|
299
301
|
onAuthStateChange={onAuthStateChange as OxyContextProviderProps['onAuthStateChange']}
|
|
300
302
|
>
|
|
301
303
|
{children}
|
|
@@ -93,7 +93,7 @@ const SignInModal: React.FC = () => {
|
|
|
93
93
|
|
|
94
94
|
const insets = useSafeAreaInsets();
|
|
95
95
|
const theme = useTheme();
|
|
96
|
-
const { oxyServices, switchSession } = useOxy();
|
|
96
|
+
const { oxyServices, switchSession, appName } = useOxy();
|
|
97
97
|
|
|
98
98
|
const socketRef = useRef<Socket | null>(null);
|
|
99
99
|
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -273,7 +273,7 @@ const SignInModal: React.FC = () => {
|
|
|
273
273
|
await oxyServices.makeRequest('POST', '/auth/session/create', {
|
|
274
274
|
sessionToken,
|
|
275
275
|
expiresAt,
|
|
276
|
-
appId:
|
|
276
|
+
appId: appName,
|
|
277
277
|
}, { cache: false });
|
|
278
278
|
|
|
279
279
|
setAuthSession({ sessionToken, expiresAt });
|
|
@@ -284,7 +284,7 @@ const SignInModal: React.FC = () => {
|
|
|
284
284
|
} finally {
|
|
285
285
|
setIsLoading(false);
|
|
286
286
|
}
|
|
287
|
-
}, [oxyServices, connectSocket]);
|
|
287
|
+
}, [oxyServices, connectSocket, appName]);
|
|
288
288
|
|
|
289
289
|
// Generate a cryptographically random session token.
|
|
290
290
|
// 16 random bytes -> 32 hex chars (128 bits of entropy) — unguessable.
|
|
@@ -42,6 +42,7 @@ import { useDeviceManagement } from '../hooks/useDeviceManagement';
|
|
|
42
42
|
import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
|
|
43
43
|
import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
|
|
44
44
|
import { readActiveAuthuser, writeActiveAuthuser } from '../utils/activeAuthuser';
|
|
45
|
+
import { resolveAppDisplayName } from '../utils/appName';
|
|
45
46
|
import type { RouteName } from '../navigation/routes';
|
|
46
47
|
import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
|
|
47
48
|
import { useQueryClient } from '@tanstack/react-query';
|
|
@@ -114,6 +115,13 @@ export interface OxyContextState {
|
|
|
114
115
|
clearSessionState: () => Promise<void>;
|
|
115
116
|
clearAllAccountData: () => Promise<void>;
|
|
116
117
|
storageKeyPrefix: string;
|
|
118
|
+
/**
|
|
119
|
+
* Resolved human-readable app display name surfaced on the central Oxy
|
|
120
|
+
* sign-in / consent experience (e.g. "Mention wants to access your Oxy
|
|
121
|
+
* account"). Always non-empty — derived from the `appName` prop, then
|
|
122
|
+
* `storageKeyPrefix`, then `document.title` (web), then the platform.
|
|
123
|
+
*/
|
|
124
|
+
appName: string;
|
|
117
125
|
oxyServices: OxyServices;
|
|
118
126
|
useFollow?: UseFollowHook;
|
|
119
127
|
showBottomSheet?: (screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => void;
|
|
@@ -140,6 +148,11 @@ export interface OxyContextProviderProps {
|
|
|
140
148
|
authWebUrl?: string;
|
|
141
149
|
authRedirectUri?: string;
|
|
142
150
|
storageKeyPrefix?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Human-readable name of the consuming app shown on the central Oxy
|
|
153
|
+
* sign-in / consent experience. See {@link OxyContextState.appName}.
|
|
154
|
+
*/
|
|
155
|
+
appName?: string;
|
|
143
156
|
onAuthStateChange?: (user: User | null) => void;
|
|
144
157
|
onError?: (error: ApiError) => void;
|
|
145
158
|
}
|
|
@@ -204,6 +217,34 @@ const SILENT_IFRAME_TIMEOUT = 2500;
|
|
|
204
217
|
*/
|
|
205
218
|
const COOKIE_RESTORE_TIMEOUT = 3000;
|
|
206
219
|
|
|
220
|
+
/**
|
|
221
|
+
* HARD overall deadline (ms) for the entire cold-boot step loop —
|
|
222
|
+
* defense-in-depth so a single non-settling step can NEVER hang auth resolution
|
|
223
|
+
* forever (the production regression: a `navigator.credentials.get()` that
|
|
224
|
+
* ignored its abort signal left the `fedcm-silent` step's promise unsettled, so
|
|
225
|
+
* `runColdBoot` never advanced to the terminal `/sso` bounce and auth hung
|
|
226
|
+
* indefinitely).
|
|
227
|
+
*
|
|
228
|
+
* Every step ALREADY bounds its own network work (the stored-session bearer
|
|
229
|
+
* validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT`, the refresh
|
|
230
|
+
* cookie at `COOKIE_RESTORE_TIMEOUT`, FedCM silent at `FEDCM_SILENT_TIMEOUT`
|
|
231
|
+
* plus its hard settle). On a healthy load the FIRST recovering step wins in a
|
|
232
|
+
* single round-trip (1–3s) and the chain short-circuits long before this fires.
|
|
233
|
+
* This budget only trips when one of those per-step bounds regresses.
|
|
234
|
+
*
|
|
235
|
+
* 20s is the chosen value: comfortably ABOVE the worst-case bounded
|
|
236
|
+
* stored-session path under transient slowness (the 8s parallel validation
|
|
237
|
+
* window plus a `switchSession` round-trip) so a genuinely slow-but-healthy
|
|
238
|
+
* reload is never cut off, yet well BELOW the ~28–30s the previous
|
|
239
|
+
* probe-first ordering took — and, critically, finite, so the user can never
|
|
240
|
+
* sit on an indefinite spinner. When the deadline trips, `runColdBoot` keeps
|
|
241
|
+
* iterating to the terminal `sso-bounce` step (whose navigation side effect
|
|
242
|
+
* runs synchronously), so a genuine no-local-session first visit STILL reaches
|
|
243
|
+
* the cross-domain `/sso` fallback. Native runs only the stored-session step,
|
|
244
|
+
* which is bounded well under this, so the deadline never alters native flow.
|
|
245
|
+
*/
|
|
246
|
+
const COLD_BOOT_OVERALL_DEADLINE = 20000;
|
|
247
|
+
|
|
207
248
|
/**
|
|
208
249
|
* Whether `idpOrigin` is a same-site, first-party host of the current page —
|
|
209
250
|
* i.e. it shares the page's registrable apex (last two labels), so a "no
|
|
@@ -273,6 +314,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
273
314
|
authWebUrl,
|
|
274
315
|
authRedirectUri,
|
|
275
316
|
storageKeyPrefix = 'oxy_session',
|
|
317
|
+
appName: appNameProp,
|
|
276
318
|
onAuthStateChange,
|
|
277
319
|
onError,
|
|
278
320
|
}) => {
|
|
@@ -382,6 +424,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
382
424
|
|
|
383
425
|
const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
|
|
384
426
|
|
|
427
|
+
// Human-readable app display name for the central sign-in / consent UI.
|
|
428
|
+
// Derived once from the consumer config; never "web" unless the app supplies
|
|
429
|
+
// no name, no custom prefix, and no document title.
|
|
430
|
+
const appName = useMemo(
|
|
431
|
+
() => resolveAppDisplayName(appNameProp, storageKeyPrefix),
|
|
432
|
+
[appNameProp, storageKeyPrefix],
|
|
433
|
+
);
|
|
434
|
+
|
|
385
435
|
// Storage initialization.
|
|
386
436
|
//
|
|
387
437
|
// `storage` (state) drives render-time gating (`isStorageReady`) and the
|
|
@@ -1144,6 +1194,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1144
1194
|
);
|
|
1145
1195
|
}
|
|
1146
1196
|
},
|
|
1197
|
+
// Defense-in-depth: a single step whose promise never settles (the
|
|
1198
|
+
// production FedCM-silent hang) can no longer block the chain forever.
|
|
1199
|
+
// On expiry the runner keeps iterating to the terminal `sso-bounce`
|
|
1200
|
+
// step so a genuine no-local-session visit still reaches the
|
|
1201
|
+
// cross-domain `/sso` fallback; the `finally` backstop flips
|
|
1202
|
+
// `authResolved` regardless. See `COLD_BOOT_OVERALL_DEADLINE`.
|
|
1203
|
+
overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
|
|
1204
|
+
onStepDeadline: (id) => {
|
|
1205
|
+
if (__DEV__) {
|
|
1206
|
+
loggerUtil.debug(
|
|
1207
|
+
`Cold-boot step "${id}" exceeded the overall deadline (abandoned, falling through)`,
|
|
1208
|
+
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1147
1212
|
});
|
|
1148
1213
|
|
|
1149
1214
|
if (__DEV__ && outcome.kind === 'session') {
|
|
@@ -1601,6 +1666,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1601
1666
|
clearSessionState,
|
|
1602
1667
|
clearAllAccountData,
|
|
1603
1668
|
storageKeyPrefix,
|
|
1669
|
+
appName,
|
|
1604
1670
|
oxyServices,
|
|
1605
1671
|
useFollow: useFollowHook,
|
|
1606
1672
|
showBottomSheet: showBottomSheetForContext,
|
|
@@ -1629,6 +1695,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1629
1695
|
logoutAllDeviceSessions,
|
|
1630
1696
|
oxyServices,
|
|
1631
1697
|
storageKeyPrefix,
|
|
1698
|
+
appName,
|
|
1632
1699
|
refreshSessionsWithUser,
|
|
1633
1700
|
sessions,
|
|
1634
1701
|
setLanguage,
|
|
@@ -1706,6 +1773,7 @@ const LOADING_STATE: OxyContextState = {
|
|
|
1706
1773
|
clearSessionState: () => rejectMissingProvider<void>(),
|
|
1707
1774
|
clearAllAccountData: () => rejectMissingProvider<void>(),
|
|
1708
1775
|
storageKeyPrefix: 'oxy_session',
|
|
1776
|
+
appName: resolveAppDisplayName(undefined, undefined),
|
|
1709
1777
|
oxyServices: LOADING_STATE_OXY_SERVICES,
|
|
1710
1778
|
openAvatarPicker: () => {},
|
|
1711
1779
|
actingAs: null,
|
|
@@ -119,7 +119,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
119
119
|
theme,
|
|
120
120
|
}) => {
|
|
121
121
|
const bloomTheme = useTheme();
|
|
122
|
-
const { oxyServices, signIn, switchSession,
|
|
122
|
+
const { oxyServices, signIn, switchSession, appName } = useOxy();
|
|
123
123
|
|
|
124
124
|
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
|
125
125
|
const [isLoading, setIsLoading] = useState(true);
|
|
@@ -273,7 +273,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
273
273
|
await oxyServices.makeRequest('POST', '/auth/session/create', {
|
|
274
274
|
sessionToken,
|
|
275
275
|
expiresAt,
|
|
276
|
-
appId:
|
|
276
|
+
appId: appName,
|
|
277
277
|
}, { cache: false });
|
|
278
278
|
|
|
279
279
|
setAuthSession({ sessionToken, expiresAt });
|
|
@@ -286,7 +286,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
286
286
|
} finally {
|
|
287
287
|
setIsLoading(false);
|
|
288
288
|
}
|
|
289
|
-
}, [oxyServices, connectSocket]);
|
|
289
|
+
}, [oxyServices, connectSocket, appName]);
|
|
290
290
|
|
|
291
291
|
// Generate a random session token
|
|
292
292
|
const generateSessionToken = (): string => {
|
|
@@ -52,6 +52,14 @@ export interface OxyProviderProps {
|
|
|
52
52
|
children?: ReactNode;
|
|
53
53
|
onAuthStateChange?: (user: unknown) => void;
|
|
54
54
|
storageKeyPrefix?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Human-readable name of the consuming app (e.g. "Mention", "Homiio").
|
|
57
|
+
* Surfaced on the central Oxy sign-in / consent experience as
|
|
58
|
+
* "{appName} wants to access your Oxy account". When omitted, the SDK
|
|
59
|
+
* derives a name from `storageKeyPrefix`, then `document.title` (web),
|
|
60
|
+
* falling back to the platform. Set this to guarantee correct branding.
|
|
61
|
+
*/
|
|
62
|
+
appName?: string;
|
|
55
63
|
baseURL?: string;
|
|
56
64
|
authWebUrl?: string;
|
|
57
65
|
authRedirectUri?: string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { resolveAppDisplayName } from '../appName';
|
|
2
|
+
|
|
3
|
+
// The shared react-native mock pins `Platform.OS` to 'web', which is exactly
|
|
4
|
+
// the platform on which the historical "web wants to access your Oxy account"
|
|
5
|
+
// regression occurred. These tests assert the resolution order that prevents it.
|
|
6
|
+
|
|
7
|
+
describe('resolveAppDisplayName', () => {
|
|
8
|
+
const originalTitle = typeof document !== 'undefined' ? document.title : '';
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (typeof document !== 'undefined') {
|
|
12
|
+
document.title = originalTitle;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('prefers an explicit appName, trimmed', () => {
|
|
17
|
+
expect(resolveAppDisplayName(' Mention ', 'oxy_session')).toBe('Mention');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('explicit appName wins over a custom storageKeyPrefix', () => {
|
|
21
|
+
expect(resolveAppDisplayName('Mention', 'homiio')).toBe('Mention');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('capitalizes a custom storageKeyPrefix when no appName is given', () => {
|
|
25
|
+
expect(resolveAppDisplayName(undefined, 'mention')).toBe('Mention');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('ignores the default storageKeyPrefix (never surfaces "Oxy_session")', () => {
|
|
29
|
+
if (typeof document !== 'undefined') {
|
|
30
|
+
document.title = '';
|
|
31
|
+
}
|
|
32
|
+
expect(resolveAppDisplayName(undefined, 'oxy_session')).toBe('web');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to document.title on web when no name or custom prefix is set', () => {
|
|
36
|
+
if (typeof document !== 'undefined') {
|
|
37
|
+
document.title = 'Homiio';
|
|
38
|
+
}
|
|
39
|
+
expect(resolveAppDisplayName(undefined, 'oxy_session')).toBe('Homiio');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to the platform only when nothing else is available', () => {
|
|
43
|
+
if (typeof document !== 'undefined') {
|
|
44
|
+
document.title = '';
|
|
45
|
+
}
|
|
46
|
+
expect(resolveAppDisplayName(undefined, undefined)).toBe('web');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('treats a whitespace-only appName as absent', () => {
|
|
50
|
+
expect(resolveAppDisplayName(' ', 'mention')).toBe('Mention');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `storageKeyPrefix` default applied by `OxyContextProvider`. When the
|
|
5
|
+
* consumer never overrides it, the prefix carries no app-identity signal and
|
|
6
|
+
* must NOT be used to derive a display name (it would surface "Oxy_session").
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_STORAGE_KEY_PREFIX = 'oxy_session';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Capitalize the first character of a non-empty string. Used to turn a lower
|
|
12
|
+
* case `storageKeyPrefix` (e.g. `"mention"`) into a presentable label
|
|
13
|
+
* (`"Mention"`). Pure; leaves the remainder untouched so multi-word or already
|
|
14
|
+
* capitalized values are preserved.
|
|
15
|
+
*/
|
|
16
|
+
function capitalizeFirst(value: string): string {
|
|
17
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a human-readable application display name for the consent / sign-in
|
|
22
|
+
* UI shown by the central Oxy auth experience (e.g. "Mention wants to access
|
|
23
|
+
* your Oxy account"). This is sent as the `appId` field on
|
|
24
|
+
* `POST /auth/session/create` and rendered verbatim by the auth consent page.
|
|
25
|
+
*
|
|
26
|
+
* Resolution order (first non-empty wins):
|
|
27
|
+
* 1. An explicit `appName` declared by the consumer on `OxyProvider`.
|
|
28
|
+
* 2. The capitalized `storageKeyPrefix` — but only when the consumer actually
|
|
29
|
+
* overrode the default. Apps already pass a brand-shaped prefix
|
|
30
|
+
* (`"mention"`, `"homiio"`, …) so this gives most apps a correct name with
|
|
31
|
+
* zero extra config.
|
|
32
|
+
* 3. On web only, a meaningful `document.title` (trimmed). This rescues
|
|
33
|
+
* zero-config web apps that set a page title but no prefix.
|
|
34
|
+
* 4. `Platform.OS` as the terminal fallback. On web this yields the historical
|
|
35
|
+
* `"web"` value — now reached ONLY when an app supplies neither an explicit
|
|
36
|
+
* name, a custom prefix, nor a document title.
|
|
37
|
+
*
|
|
38
|
+
* The result is never empty.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveAppDisplayName(
|
|
41
|
+
appName: string | undefined,
|
|
42
|
+
storageKeyPrefix: string | undefined,
|
|
43
|
+
): string {
|
|
44
|
+
const explicit = appName?.trim();
|
|
45
|
+
if (explicit) {
|
|
46
|
+
return explicit;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const prefix = storageKeyPrefix?.trim();
|
|
50
|
+
if (prefix && prefix !== DEFAULT_STORAGE_KEY_PREFIX) {
|
|
51
|
+
return capitalizeFirst(prefix);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
|
55
|
+
const title = document.title?.trim();
|
|
56
|
+
if (title) {
|
|
57
|
+
return title;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Platform.OS;
|
|
62
|
+
}
|