@arcblock/did-connect-react 3.1.0

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.
Files changed (183) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +134 -0
  3. package/lib/Address/index.js +4 -0
  4. package/lib/Avatar/index.js +4 -0
  5. package/lib/Button/index.js +17 -0
  6. package/lib/Connect/assets/locale.js +143 -0
  7. package/lib/Connect/assets/login-bg.png +0 -0
  8. package/lib/Connect/assets/login-slogan.js +9 -0
  9. package/lib/Connect/components/action-button.js +26 -0
  10. package/lib/Connect/components/app-tips.js +132 -0
  11. package/lib/Connect/components/auto-height.js +31 -0
  12. package/lib/Connect/components/back-button.js +24 -0
  13. package/lib/Connect/components/connect-status.js +263 -0
  14. package/lib/Connect/components/did-connect-title.js +126 -0
  15. package/lib/Connect/components/download-tips.js +52 -0
  16. package/lib/Connect/components/loading.js +26 -0
  17. package/lib/Connect/components/login-item/connect-choose-list.js +249 -0
  18. package/lib/Connect/components/login-item/login-method-item.js +129 -0
  19. package/lib/Connect/components/login-item/mobile-login-item.js +114 -0
  20. package/lib/Connect/components/login-item/passkey-login-item.js +44 -0
  21. package/lib/Connect/components/login-item/web-login-item.js +97 -0
  22. package/lib/Connect/components/mask-overlay.js +34 -0
  23. package/lib/Connect/components/refresh-overlay.js +57 -0
  24. package/lib/Connect/components/switch-app.js +70 -0
  25. package/lib/Connect/contexts/state.js +142 -0
  26. package/lib/Connect/fullpage.js +5 -0
  27. package/lib/Connect/hooks/auth-url.js +23 -0
  28. package/lib/Connect/hooks/method-list.js +46 -0
  29. package/lib/Connect/hooks/page-show.js +17 -0
  30. package/lib/Connect/hooks/security.js +27 -0
  31. package/lib/Connect/hooks/token.js +305 -0
  32. package/lib/Connect/hooks/use-apps.js +19 -0
  33. package/lib/Connect/hooks/use-quick-connect.js +97 -0
  34. package/lib/Connect/index.js +498 -0
  35. package/lib/Connect/landing-page.js +5 -0
  36. package/lib/Connect/plugins/email/index.js +62 -0
  37. package/lib/Connect/plugins/email/list-item.js +28 -0
  38. package/lib/Connect/plugins/email/placeholder.js +283 -0
  39. package/lib/Connect/plugins/index.js +4 -0
  40. package/lib/Connect/use-connect.js +164 -0
  41. package/lib/Connect/with-blocklet.js +15 -0
  42. package/lib/Connect/with-bridge-call.js +108 -0
  43. package/lib/Federated/context.js +61 -0
  44. package/lib/Federated/index.js +7 -0
  45. package/lib/Logo/index.js +4 -0
  46. package/lib/OAuth/context.js +234 -0
  47. package/lib/OAuth/guest.svg.js +5 -0
  48. package/lib/OAuth/index.js +7 -0
  49. package/lib/OAuth/passport-switcher.js +114 -0
  50. package/lib/Passkey/actions.js +165 -0
  51. package/lib/Passkey/constants.js +4 -0
  52. package/lib/Passkey/context.js +266 -0
  53. package/lib/Passkey/dialog.js +277 -0
  54. package/lib/Passkey/icon.js +13 -0
  55. package/lib/Passkey/index.js +9 -0
  56. package/lib/Service/index.js +62 -0
  57. package/lib/Session/assets/did-spaces-guide-cover.svg.js +135 -0
  58. package/lib/Session/assets/did-spaces-guide-icon.svg.js +9 -0
  59. package/lib/Session/context.js +5 -0
  60. package/lib/Session/did-spaces-guide.js +136 -0
  61. package/lib/Session/hooks/use-federated.js +64 -0
  62. package/lib/Session/hooks/use-mobile.js +8 -0
  63. package/lib/Session/hooks/use-protected-routes.js +11 -0
  64. package/lib/Session/hooks/use-session-token.js +169 -0
  65. package/lib/Session/hooks/use-verify.js +45 -0
  66. package/lib/Session/index.js +896 -0
  67. package/lib/Session/libs/constants.js +15 -0
  68. package/lib/Session/libs/did-spaces.js +10 -0
  69. package/lib/Session/libs/federated.js +42 -0
  70. package/lib/Session/libs/index.js +15 -0
  71. package/lib/Session/libs/locales.js +161 -0
  72. package/lib/Session/libs/login-mobile.js +55 -0
  73. package/lib/Session/window-focus-aware.js +17 -0
  74. package/lib/SessionManager/index.js +4 -0
  75. package/lib/Storage/engine/cookie.js +21 -0
  76. package/lib/Storage/engine/local-storage.js +36 -0
  77. package/lib/Storage/index.js +23 -0
  78. package/lib/User/index.js +6 -0
  79. package/lib/User/use-did.js +59 -0
  80. package/lib/User/wrap-did.js +13 -0
  81. package/lib/WebWalletSWKeeper/index.js +5 -0
  82. package/lib/constant.js +22 -0
  83. package/lib/error.js +8 -0
  84. package/lib/hooks/use-locale.js +7 -0
  85. package/lib/index.js +33 -0
  86. package/lib/locales/en.js +17 -0
  87. package/lib/locales/index.js +10 -0
  88. package/lib/locales/zh.js +17 -0
  89. package/lib/package.json.js +7 -0
  90. package/lib/types.d.ts +355 -0
  91. package/lib/utils.js +214 -0
  92. package/package.json +84 -0
  93. package/src/Address/index.jsx +2 -0
  94. package/src/Avatar/index.jsx +2 -0
  95. package/src/Button/Button.stories.jsx +7 -0
  96. package/src/Button/index.jsx +21 -0
  97. package/src/Connect/Connect.stories.jsx +34 -0
  98. package/src/Connect/assets/locale.js +145 -0
  99. package/src/Connect/assets/login-bg.png +0 -0
  100. package/src/Connect/assets/login-slogan.js +7 -0
  101. package/src/Connect/components/action-button.jsx +22 -0
  102. package/src/Connect/components/app-tips.jsx +156 -0
  103. package/src/Connect/components/auto-height.jsx +38 -0
  104. package/src/Connect/components/back-button.jsx +23 -0
  105. package/src/Connect/components/connect-status.jsx +259 -0
  106. package/src/Connect/components/did-connect-title.jsx +106 -0
  107. package/src/Connect/components/download-tips.jsx +55 -0
  108. package/src/Connect/components/loading.jsx +25 -0
  109. package/src/Connect/components/login-item/connect-choose-list.jsx +304 -0
  110. package/src/Connect/components/login-item/login-method-item.jsx +118 -0
  111. package/src/Connect/components/login-item/mobile-login-item.jsx +179 -0
  112. package/src/Connect/components/login-item/passkey-login-item.jsx +52 -0
  113. package/src/Connect/components/login-item/web-login-item.jsx +149 -0
  114. package/src/Connect/components/mask-overlay.jsx +32 -0
  115. package/src/Connect/components/refresh-overlay.jsx +52 -0
  116. package/src/Connect/components/switch-app.jsx +69 -0
  117. package/src/Connect/contexts/state.jsx +219 -0
  118. package/src/Connect/fullpage.jsx +3 -0
  119. package/src/Connect/hooks/auth-url.js +31 -0
  120. package/src/Connect/hooks/method-list.js +121 -0
  121. package/src/Connect/hooks/page-show.js +24 -0
  122. package/src/Connect/hooks/security.js +40 -0
  123. package/src/Connect/hooks/token.js +639 -0
  124. package/src/Connect/hooks/use-apps.js +69 -0
  125. package/src/Connect/hooks/use-quick-connect.js +130 -0
  126. package/src/Connect/index.jsx +600 -0
  127. package/src/Connect/landing-page.jsx +3 -0
  128. package/src/Connect/plugins/email/index.jsx +82 -0
  129. package/src/Connect/plugins/email/list-item.jsx +31 -0
  130. package/src/Connect/plugins/email/placeholder.jsx +365 -0
  131. package/src/Connect/plugins/index.js +2 -0
  132. package/src/Connect/use-connect.jsx +321 -0
  133. package/src/Connect/with-blocklet.jsx +26 -0
  134. package/src/Connect/with-bridge-call.jsx +138 -0
  135. package/src/Federated/context.jsx +93 -0
  136. package/src/Federated/index.jsx +1 -0
  137. package/src/Logo/index.jsx +2 -0
  138. package/src/OAuth/context.jsx +346 -0
  139. package/src/OAuth/guest.svg +20 -0
  140. package/src/OAuth/index.jsx +1 -0
  141. package/src/OAuth/passport-switcher.jsx +133 -0
  142. package/src/Passkey/actions.jsx +212 -0
  143. package/src/Passkey/constants.js +2 -0
  144. package/src/Passkey/context.jsx +381 -0
  145. package/src/Passkey/dialog.jsx +391 -0
  146. package/src/Passkey/icon.jsx +10 -0
  147. package/src/Passkey/index.jsx +2 -0
  148. package/src/Service/index.jsx +96 -0
  149. package/src/Session/assets/did-spaces-guide-cover.svg +128 -0
  150. package/src/Session/assets/did-spaces-guide-icon.svg +7 -0
  151. package/src/Session/context.jsx +7 -0
  152. package/src/Session/did-spaces-guide.jsx +173 -0
  153. package/src/Session/hooks/use-federated.js +88 -0
  154. package/src/Session/hooks/use-mobile.jsx +6 -0
  155. package/src/Session/hooks/use-protected-routes.js +16 -0
  156. package/src/Session/hooks/use-session-token.js +365 -0
  157. package/src/Session/hooks/use-verify.jsx +76 -0
  158. package/src/Session/index.jsx +1687 -0
  159. package/src/Session/libs/constants.js +14 -0
  160. package/src/Session/libs/did-spaces.js +38 -0
  161. package/src/Session/libs/federated.js +79 -0
  162. package/src/Session/libs/index.js +5 -0
  163. package/src/Session/libs/locales.js +160 -0
  164. package/src/Session/libs/login-mobile.js +80 -0
  165. package/src/Session/window-focus-aware.jsx +28 -0
  166. package/src/SessionManager/index.jsx +2 -0
  167. package/src/Storage/engine/cookie.js +23 -0
  168. package/src/Storage/engine/local-storage.js +55 -0
  169. package/src/Storage/index.js +25 -0
  170. package/src/User/index.js +4 -0
  171. package/src/User/use-did.js +80 -0
  172. package/src/User/wrap-did.jsx +18 -0
  173. package/src/WebWalletSWKeeper/index.jsx +3 -0
  174. package/src/constant.js +26 -0
  175. package/src/error.js +6 -0
  176. package/src/hooks/use-locale.jsx +6 -0
  177. package/src/index.js +43 -0
  178. package/src/locales/en.jsx +15 -0
  179. package/src/locales/index.jsx +13 -0
  180. package/src/locales/zh.jsx +15 -0
  181. package/src/types.d.ts +355 -0
  182. package/src/utils.js +395 -0
  183. package/vite.config.mjs +29 -0
@@ -0,0 +1,40 @@
1
+ import tweetnacl from 'tweetnacl';
2
+ import { useLocalStorageState } from 'ahooks';
3
+ import { useEffect } from 'react';
4
+
5
+ import { encodeKey, decrypt as _decrypt, encrypt as _encrypt } from '../../utils';
6
+
7
+ const keyPair = tweetnacl.box.keyPair();
8
+
9
+ export default function useSecurity() {
10
+ const [encryptKey, setEncryptKey] = useLocalStorageState('__encKey', {
11
+ defaultValue: encodeKey(keyPair.publicKey),
12
+ deserializer: (str) => str,
13
+ serializer: (str) => str,
14
+ });
15
+ const [decryptKey, setDecryptKey] = useLocalStorageState('__decKey', {
16
+ defaultValue: encodeKey(keyPair.secretKey),
17
+ deserializer: (str) => str,
18
+ serializer: (str) => str,
19
+ });
20
+
21
+ useEffect(() => {
22
+ if (!localStorage.getItem('__encKey')) {
23
+ setEncryptKey(encodeKey(keyPair.publicKey));
24
+ }
25
+ if (!localStorage.getItem('__decKey')) {
26
+ setDecryptKey(encodeKey(keyPair.secretKey));
27
+ }
28
+ // eslint-disable-next-line react-hooks/exhaustive-deps
29
+ }, []);
30
+
31
+ const decrypt = (v, _encryptKey = encryptKey, _decryptKey = decryptKey) => _decrypt(v, _encryptKey, _decryptKey);
32
+ const encrypt = (v, _encryptKey = encryptKey) => _encrypt(v, _encryptKey);
33
+
34
+ return {
35
+ encryptKey,
36
+ decryptKey,
37
+ decrypt,
38
+ encrypt,
39
+ };
40
+ }
@@ -0,0 +1,639 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import get from 'lodash/get';
3
+ import Cookie from 'js-cookie';
4
+ import omitBy from 'lodash/omitBy';
5
+ import isNil from 'lodash/isNil';
6
+ import omit from 'lodash/omit';
7
+ import cloneDeep from 'lodash/cloneDeep';
8
+ import { useCreation, useMemoizedFn, useReactive } from 'ahooks';
9
+ import useInterval from '@arcblock/react-hooks/lib/useInterval';
10
+ import useBrowser from '@arcblock/react-hooks/lib/useBrowser';
11
+ import { getVisitorId, isUrl, stringifyQuery } from '@arcblock/ux/lib/Util';
12
+ import { WsClient } from '@arcblock/ws';
13
+
14
+ import {
15
+ decodeConnectUrl,
16
+ parseTokenFromConnectUrl,
17
+ updateConnectedInfo,
18
+ parseNextWorkflow,
19
+ createAxios,
20
+ sleep,
21
+ getConnectedInfo,
22
+ } from '../../utils';
23
+
24
+ import translations from '../assets/locale';
25
+ import useSecurity from './security';
26
+ import usePageShow from './page-show';
27
+ import { RELAY_SOCKET_PREFIX } from '../../constant';
28
+
29
+ function getSocketHost(baseUrl) {
30
+ const targetUrl = baseUrl || window.location.href;
31
+ return new URL(targetUrl).host;
32
+ }
33
+
34
+ function getExtraHeaders(baseUrl) {
35
+ const headers = {};
36
+ const authUrl = baseUrl || window.location.href;
37
+
38
+ if (authUrl) {
39
+ const { hostname, protocol, port } = new URL(authUrl);
40
+ headers['x-real-hostname'] = hostname;
41
+ headers['x-real-port'] = port;
42
+ headers['x-real-protocol'] = protocol.endsWith(':') ? protocol.substring(0, protocol.length - 1) : protocol;
43
+ }
44
+ return headers;
45
+ }
46
+
47
+ const fns = {};
48
+ function createTokenFn({ action, prefix, checkFn, extraParams, baseUrl }) {
49
+ const key = `${prefix}/token?${stringifyQuery(extraParams)}`;
50
+ if (!fns[key]) {
51
+ fns[key] = async function createToken() {
52
+ const startTime = +new Date();
53
+ const res = await checkFn(key, { headers: getExtraHeaders(baseUrl) });
54
+ const endTime = +new Date();
55
+ if (endTime - startTime < 500) {
56
+ await sleep(500 - (endTime - startTime));
57
+ }
58
+ if (res?.data?.token) {
59
+ return res.data;
60
+ }
61
+
62
+ if (res?.data?.error) {
63
+ throw new Error(res.data.error);
64
+ }
65
+
66
+ const errorUrl = isUrl(prefix) ? prefix : `${baseUrl}${prefix}`;
67
+ throw new Error(`Error generating ${action} QR Code from: ${errorUrl}`);
68
+ };
69
+ }
70
+ return fns[key];
71
+ }
72
+
73
+ // 从 url params 中获取已存在的 session (token & connect url)
74
+ const parseExistingSession = () => {
75
+ try {
76
+ const url = new URL(window.location.href);
77
+ const connectUrlParam = url.searchParams.get('__connect_url__');
78
+ if (!connectUrlParam) {
79
+ return null;
80
+ }
81
+ const connectUrl = decodeConnectUrl(connectUrlParam);
82
+ const token = parseTokenFromConnectUrl(connectUrl);
83
+ return {
84
+ token,
85
+ url: connectUrl,
86
+ };
87
+ } catch (e) {
88
+ return {
89
+ error: e,
90
+ };
91
+ }
92
+ };
93
+
94
+ // FIXME: @zhanghan 当 connect 组件使用不同的 baseUrl 时,不应该直接从 window.blocklet 去取值
95
+ const getAppId = () =>
96
+ get(globalThis, 'blocklet.appPid') || get(globalThis, 'blocklet.appId') || get(globalThis, 'env.appId') || '';
97
+ const getAppPrefix = () =>
98
+ (get(globalThis, 'env.apiPrefix') || '/').replace(/\/$/, '').replace(RELAY_SOCKET_PREFIX, '');
99
+ const getRelayChannel = (token) => `relay:${getAppId()}:${token}`;
100
+ const getRelayProtocol = () => (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
101
+ const isSameOrigin = (baseUrl) => !baseUrl || new URL(baseUrl).origin === window.location.origin;
102
+ const isSessionEnded = (status) => ['succeed', 'error', 'timeout', 'busy'].includes(status);
103
+
104
+ export default function useToken({
105
+ action,
106
+ checkFn,
107
+ checkInterval,
108
+ checkTimeout,
109
+ extraParams,
110
+ locale,
111
+ prefix,
112
+ tokenKey,
113
+ encKey,
114
+ onError,
115
+ onSuccess,
116
+ baseUrl,
117
+ autoConnect = true,
118
+ forceConnected = true,
119
+ saveConnect = true, // FIXME: @zhanghan 将来需要设置为默认 false,仅在需要的时候进行保存 login, switch/role/profile
120
+ useSocket = true,
121
+ provider = 'wallet',
122
+ }) {
123
+ /**
124
+ * @type {import('../../types').TokenState}
125
+ */
126
+ const state = useReactive({
127
+ checking: false,
128
+ loading: false,
129
+ token: '',
130
+ url: '',
131
+ store: null,
132
+ status: 'created',
133
+ error: '',
134
+ checkCount: 0,
135
+ mfaCode: 0,
136
+ appInfo: null,
137
+ memberAppInfo: null,
138
+ connectedDid: '',
139
+ saveConnect: false,
140
+ inExistingSession: false,
141
+ nextWorkflow: '',
142
+ baseUrl,
143
+ prefix: `${prefix}/${action}`,
144
+ extraParams: omitBy(extraParams, isNil),
145
+ checkFn,
146
+ results: {},
147
+ action,
148
+ provider,
149
+ reset() {
150
+ this.error = '';
151
+ this.status = 'created';
152
+ },
153
+ });
154
+
155
+ const statusExtraParams = useRef(null);
156
+
157
+ const translation = translations[locale] || translations.en;
158
+ const existingSession = useCreation(() => parseExistingSession(), []);
159
+ const maxCheckCount = Math.ceil(checkTimeout / checkInterval);
160
+
161
+ const currentState = useReactive({
162
+ onSuccessCalled: false,
163
+ cancelWhenScannedCounter: 0,
164
+ isSocketAvailable: false,
165
+ });
166
+
167
+ const { encryptKey, decrypt } = useSecurity();
168
+
169
+ // scanned 状态下 cancel (Back 按钮) 操作的计数器, 每次 cancel 操作时 +1
170
+ // - 外部可以传入 autoConnect 表示是否启用自动连接
171
+ // - 计数器默认为 0, 表示还未进行过 cancel 操作
172
+ // - 如果计数器 > 0, 说明用户进行过 cancel 操作
173
+ // (临时禁用, 重新打开 did-connect 窗口时恢复)
174
+ const browser = useBrowser();
175
+ const visitorId = getVisitorId();
176
+ const socket = useRef(null);
177
+ const subscription = useRef(null);
178
+
179
+ const params = {
180
+ ...state.extraParams,
181
+ locale,
182
+ [tokenKey]: state.token || existingSession?.token,
183
+ provider: state.provider,
184
+ };
185
+
186
+ const unsubscribe = async () => {
187
+ // HACK: 如果 url 中已经存在 session,则不允许注销当前的 session(需要利用当前 session 作为 sourceToken 去创建一个新的 session)
188
+ if (existingSession?.token && existingSession?.token === state.token) {
189
+ return;
190
+ }
191
+ try {
192
+ if (params[tokenKey] && state.status === 'created') {
193
+ await state.checkFn(`${state.prefix}/timeout?${stringifyQuery(params)}`, {
194
+ headers: getExtraHeaders(state.baseUrl),
195
+ });
196
+ }
197
+ } catch (err) {
198
+ // Do nothing
199
+ }
200
+
201
+ try {
202
+ if (state.token && subscription.current) {
203
+ await socket.current.unsubscribe(getRelayChannel(state.token));
204
+ subscription.current = null;
205
+ }
206
+ } catch (err) {
207
+ // Do nothing
208
+ }
209
+ };
210
+
211
+ // HACK: 为了保证 createToken 函数永远都是最新的,需要使用 useMemoizedFn 来包裹
212
+ const createToken = useMemoizedFn((...args) => {
213
+ const innerParams = {
214
+ [encKey]: encryptKey,
215
+ ...state.extraParams,
216
+ locale,
217
+ forceConnected,
218
+ // - autoConnect 请求参数用于控制服务端是否给钱包应用发送自动连接通知
219
+ // - 使用 connect 组件时明确传入了 autoConnect = false, 则 autoConnect 请求参数 为 false
220
+ // - 如果 cancelWhenScannedCounter > 0, 说明用户进行过 cancel 操作, 则临时禁用自动连接, autoConnect 请求参数 为 false
221
+ // (避免 "无限自动连接问题")
222
+ // - 如果上次连接设备 (connected_wallet_os) 不是 ios/android, 则禁用自动连接
223
+ autoConnect:
224
+ autoConnect &&
225
+ // 如果是 wallet webview 环境, 不发送通知, 避免 wallet 连续弹出 auth 窗口 2 次 (#341)
226
+ !(browser.wallet || browser.arcSphere) &&
227
+ !currentState.cancelWhenScannedCounter &&
228
+ ['ios', 'android'].includes(Cookie.get('connected_wallet_os')),
229
+ provider: state.provider,
230
+ };
231
+
232
+ if (visitorId) {
233
+ // NOTICE: 这个 visitorId 必须保留,才能在三方的 did connect 流程中正确传递
234
+ innerParams.visitorId = visitorId;
235
+ }
236
+
237
+ const mergeInData = omit(statusExtraParams.current || {}, ['sourceAppPid', 'verbose']);
238
+ Object.keys(mergeInData).forEach((x) => {
239
+ if (!innerParams[x] || (x === encKey && mergeInData[x])) {
240
+ innerParams[x] = mergeInData[x];
241
+ }
242
+ });
243
+ if (existingSession?.token && !innerParams.sourceToken) {
244
+ innerParams.sourceToken = existingSession?.token;
245
+ }
246
+ const fn = createTokenFn({
247
+ action,
248
+ prefix: state.prefix,
249
+ baseUrl: state.baseUrl,
250
+ checkFn: state.checkFn,
251
+ extraParams: innerParams,
252
+ });
253
+ return fn(...args);
254
+ });
255
+
256
+ // 切换账户或者 token 过期之后需要重新生成
257
+ const generate = useMemoizedFn(async (cleanup = true) => {
258
+ if (cleanup) {
259
+ unsubscribe();
260
+ }
261
+
262
+ try {
263
+ state.loading = true;
264
+ state.token = '';
265
+ state.url = '';
266
+ state.store = null;
267
+ state.extraParams = omitBy(extraParams, isNil);
268
+ // HACK: 更改了 createToken 中依赖的参数,需要强制等待 createToken 函数更新后,再调用 createToken 函数
269
+ await sleep();
270
+
271
+ const data = await createToken();
272
+ const extra = data.extra || {};
273
+
274
+ state.loading = false;
275
+ state.token = data.token;
276
+ state.url = data.url;
277
+ state.status = 'created';
278
+ state.error = '';
279
+ state.checkCount = 0;
280
+ state.appInfo = data.appInfo;
281
+ state.memberAppInfo = data.memberAppInfo;
282
+ state.connectedDid = extra.connectedDid;
283
+ state.saveConnect = extra.saveConnect;
284
+ } catch (err) {
285
+ state.loading = false;
286
+ state.status = 'error';
287
+ state.error = `${translation.generateError}: ${err.message}`;
288
+ }
289
+ });
290
+
291
+ // 每次 cancel 操作时计数器 +1 => 重新生成 token
292
+ const cancelWhenScanned = useMemoizedFn(() => {
293
+ currentState.cancelWhenScannedCounter++;
294
+ });
295
+ useEffect(() => {
296
+ // FIXME: @zhanghan 在有 nw 的情况下,需要考虑如何进行 regenrate
297
+ // 计数器 > 0, 说明人为触发了 cancel, 重新生成 token,但是只在没有 nextWorkflow 的情况下重新生成
298
+ if (currentState.cancelWhenScannedCounter > 0) {
299
+ if (extraParams.nw) {
300
+ onError(new Error(translation.retryForbidden));
301
+ } else {
302
+ generate();
303
+ }
304
+ }
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ }, [currentState.cancelWhenScannedCounter]);
307
+
308
+ useEffect(() => {
309
+ const isSafari = /^((?!chrome|android).)*safari/i.test(globalThis.navigator.userAgent);
310
+ const disableSocket = isSafari || Object.hasOwn(globalThis?.blocklet || {}, 'DID_CONNECT_DISABLE_SOCKET');
311
+ // FIXME: @zhanghan websocket 默认支持跨域,这里为何要检查域名是同源
312
+ if (!disableSocket && useSocket && isSameOrigin(state.baseUrl) && getAppId()) {
313
+ const needReconnect =
314
+ !socket.current || socket.current.isConnected() === false || socket.current.baseUrl !== state.baseUrl;
315
+ if (needReconnect) {
316
+ socket.current = new WsClient(
317
+ `${getRelayProtocol()}//${getSocketHost(state.baseUrl)}${getAppPrefix()}${RELAY_SOCKET_PREFIX}/relay`,
318
+ {
319
+ longpollerTimeout: 5000, // connection timeout
320
+ heartbeatIntervalMs: 30 * 1000,
321
+ }
322
+ );
323
+ socket.current.baseUrl = state.baseUrl;
324
+ socket.current.onOpen(() => {
325
+ currentState.isSocketAvailable = true;
326
+ });
327
+ socket.current.connect();
328
+ }
329
+ } else if (currentState.isSocketAvailable) {
330
+ currentState.isSocketAvailable = false;
331
+ }
332
+
333
+ return () => {
334
+ if (socket.current) {
335
+ socket.current.disconnect();
336
+ socket.current = null;
337
+ }
338
+ };
339
+ }, [state.baseUrl]); // eslint-disable-line react-hooks/exhaustive-deps
340
+
341
+ const getCheckInterval = () => {
342
+ if (!state.token) {
343
+ return null;
344
+ }
345
+ if (state.loading) {
346
+ return null;
347
+ }
348
+ if (isSessionEnded(state.status)) {
349
+ return null;
350
+ }
351
+
352
+ return checkInterval;
353
+ };
354
+
355
+ // 任何导致 Connect 组件 unmounted 的操作 (比如 dialog 关闭) => 调用 timeout api 清除 token
356
+ // 关闭时如果 token 状态处于 created,那么应该通知后端给删掉
357
+ // 说明: 此处需要借助 useRef 在 useEffect return function 中访问 state
358
+ const closeSessionRef = useRef(null);
359
+ useEffect(() => {
360
+ closeSessionRef.current = { state, params };
361
+ });
362
+ useEffect(() => {
363
+ return () => {
364
+ // eslint-disable-next-line no-shadow
365
+ const { state, params } = closeSessionRef.current;
366
+ if (state.status === 'created') {
367
+ if (params[tokenKey]) {
368
+ state.checkFn(`${state.prefix}/timeout?${stringifyQuery(params)}`, {
369
+ headers: getExtraHeaders(state.baseUrl),
370
+ });
371
+ }
372
+ }
373
+ };
374
+ // eslint-disable-next-line react-hooks/exhaustive-deps
375
+ }, []);
376
+
377
+ // Check auth token status
378
+ // FIXME: @zhanghan 这个函数会造成 connect 组件每秒重渲染一次,需要从 setState 入手,优化这个问题。优化的原则是,页面数据没有变更时,不应该触发重渲染
379
+ const checkStatus = useMemoizedFn(async (force = false) => {
380
+ if ((state.checking || document.hidden) && !force) {
381
+ return null;
382
+ }
383
+
384
+ // NOTICE: 仅当状态为 created 时才去计算次数统计
385
+ if (state.status === 'created') {
386
+ state.checkCount++;
387
+ }
388
+
389
+ if (currentState.isSocketAvailable && !force) {
390
+ return null;
391
+ }
392
+
393
+ try {
394
+ state.checking = true;
395
+ const res = await state.checkFn(`${state.prefix}/status?${stringifyQuery(params)}`, {
396
+ headers: getExtraHeaders(state.baseUrl),
397
+ });
398
+ const { status, error: newError, mfaCode = 0 } = res.data;
399
+
400
+ state.store = res.data;
401
+ state.mfaCode = mfaCode;
402
+ state.checking = false;
403
+ if (state.status === 'scanned' && status === 'created') {
404
+ // HACK: 不把 scanned 状态切换回 created
405
+ } else {
406
+ state.status = status;
407
+ }
408
+ if (status === 'error' && newError) {
409
+ const err = new Error(newError);
410
+ err.response = res;
411
+ throw err;
412
+ }
413
+ return res.data;
414
+ } catch (err) {
415
+ const { response } = err;
416
+ if (response?.status) {
417
+ if (params[tokenKey]) {
418
+ state.checkFn(`${state.prefix}/timeout?${stringifyQuery(params)}`, {
419
+ headers: getExtraHeaders(state.baseUrl),
420
+ });
421
+ }
422
+ const _msg = response?.data?.error ? response.data.error : err.message;
423
+ const _err = new Error(_msg);
424
+ _err.code = response.status;
425
+ state.status = 'error';
426
+ state.checking = false;
427
+ state.error = _msg;
428
+ onError(_err, state?.store, decrypt);
429
+ } else {
430
+ state.status = 'error';
431
+ state.checking = false;
432
+ state.error = translation.generateError;
433
+ }
434
+ }
435
+ return null;
436
+ });
437
+
438
+ const applyExistingToken = async () => {
439
+ try {
440
+ if (existingSession.error) {
441
+ throw existingSession.error;
442
+ }
443
+ state.loading = true;
444
+ state.token = existingSession.token;
445
+ state.url = existingSession.url;
446
+ state.inExistingSession = true;
447
+ let data = await checkStatus();
448
+
449
+ // 1. 如果 existingSession 当前状态已经是 scanned,则刷新页面后,应该根据 existingSession.token 生成一个新的 session,继续完成后续的操作
450
+ // 2. HACK: 如果当前 existingSession 已经产生过新的 session,则目前已经无法通过 existingSession 本身来监听变化了,需要再创建一个新的 session 才能完成监听状态
451
+ if (['scanned'].includes(data?.status) || data?.sourceToken === existingSession?.token) {
452
+ await generate(false);
453
+ data = await checkStatus();
454
+ }
455
+ statusExtraParams.current = data?.extraParams || null;
456
+ // existingSession.token 合法的初始状态必须是 created
457
+ if (data?.status && !['created'].includes(data?.status)) {
458
+ throw new Error(`${translation.invalidSessionStatus} [${data.status}]`);
459
+ }
460
+ } catch (e) {
461
+ state.status = 'error';
462
+ state.error = e.message;
463
+ onError(e, state?.store, decrypt);
464
+ } finally {
465
+ state.loading = false;
466
+ }
467
+ };
468
+
469
+ // eslint-disable-next-line react-hooks/exhaustive-deps
470
+ useEffect(() => {
471
+ if (!state.token && !state.store && !state.loading && !state.error) {
472
+ // connect to existing session if any
473
+ if (existingSession) {
474
+ applyExistingToken();
475
+ // Create our first token if we do not have one
476
+ } else {
477
+ generate(false);
478
+ return;
479
+ }
480
+ }
481
+
482
+ // Mark token as expired if exceed max retry count
483
+ if (state.status !== 'timeout' && state.checkCount > maxCheckCount) {
484
+ state.status = 'timeout';
485
+ unsubscribe();
486
+ return;
487
+ }
488
+
489
+ // Trigger on success if we completed the process
490
+ if (state.status === 'succeed' && !currentState.onSuccessCalled) {
491
+ // save connected_did to cookie if only on same origin
492
+ const connectedInfo = getConnectedInfo({
493
+ ...state.store,
494
+ appInfo: state.appInfo,
495
+ memberAppInfo: state.memberAppInfo,
496
+ });
497
+ if (isSameOrigin(state.baseUrl) && saveConnect && state.saveConnect && state.store.did) {
498
+ updateConnectedInfo(connectedInfo, true);
499
+ }
500
+
501
+ if (state.store) {
502
+ const { nextWorkflow } = state.store;
503
+ // Switch to nextWorkflow if we have
504
+ if (nextWorkflow) {
505
+ try {
506
+ const parsed = parseNextWorkflow(nextWorkflow, tokenKey);
507
+ state.nextWorkflow = parsed?.nextWorkflow || nextWorkflow;
508
+ Object.assign(state, parsed);
509
+ state.store = null;
510
+ state.error = '';
511
+ state.url = '';
512
+ state.appInfo = null;
513
+ state.memberAppInfo = null;
514
+ state.checkCount = 0;
515
+ state.status = 'scanned';
516
+ state.results = {
517
+ ...state.results,
518
+ [state.token]: state.store,
519
+ };
520
+ state.checkFn = createAxios({ baseURL: parsed.baseUrl, timeout: 8000 }).get;
521
+ checkStatus();
522
+ } catch (err) {
523
+ console.error(`Invalid nextWorkflow: ${nextWorkflow}`, err);
524
+ state.status = 'error';
525
+ state.error = `Invalid nextWorkflow: ${nextWorkflow}: ${err.message}`;
526
+ }
527
+ } else {
528
+ if (state.nextWorkflow) {
529
+ state.nextWorkflow = '';
530
+ state.url = '';
531
+ }
532
+
533
+ // trigger success callback when last workflow complete
534
+ if (typeof onSuccess === 'function') {
535
+ const results = Object.values({ ...state.results, [state.token]: state.store });
536
+ currentState.onSuccessCalled = true;
537
+ const result = results.length > 1 ? results : results[0];
538
+ const clonedResult = cloneDeep(result);
539
+ onSuccess(clonedResult, decrypt, {
540
+ sourceAppPid: extraParams?.sourceAppPid,
541
+ ...connectedInfo,
542
+ });
543
+ }
544
+ }
545
+ }
546
+ return;
547
+ }
548
+
549
+ // FIXME: @zhanghan 待调查最近 ws 通道不会将 status 设置为 status,而直接进行到下一 nextWorkflow status 为 scanned 的情况
550
+ // 此处仅用于处理上述情况
551
+ if (state.store?.nextWorkflow) {
552
+ const { nextWorkflow } = state.store;
553
+ try {
554
+ const parsed = parseNextWorkflow(nextWorkflow, tokenKey);
555
+ state.nextWorkflow = parsed?.nextWorkflow || nextWorkflow;
556
+ Object.assign(state, parsed);
557
+ state.store = null;
558
+ state.error = '';
559
+ state.url = '';
560
+ state.appInfo = null;
561
+ state.memberAppInfo = null;
562
+ state.checkCount = 0;
563
+ state.status = 'scanned';
564
+ state.results = {
565
+ ...state.results,
566
+ [state.token]: state.store,
567
+ };
568
+ state.checkFn = createAxios({ baseURL: parsed.baseUrl, timeout: 8000 }).get;
569
+ checkStatus();
570
+ } catch (err) {
571
+ console.error(`Invalid nextWorkflow: ${nextWorkflow}`, err);
572
+ state.status = 'error';
573
+ state.error = `Invalid nextWorkflow: ${nextWorkflow}: ${err.message}`;
574
+ }
575
+ }
576
+ });
577
+ const subscriptionUpdatedAt = useRef(0);
578
+
579
+ // Try to use websocket
580
+ useEffect(() => {
581
+ if (state.token && currentState.isSocketAvailable && socket.current) {
582
+ let needSubscription = false;
583
+ if (subscription.current) {
584
+ if (subscription.current.token !== state.token) {
585
+ socket.current.unsubscribe(getRelayChannel(subscription.current.token));
586
+ needSubscription = true;
587
+ }
588
+ } else {
589
+ needSubscription = true;
590
+ }
591
+
592
+ if (needSubscription) {
593
+ subscription.current = socket.current.subscribe(getRelayChannel(state.token));
594
+ // 每次重新监听时,需要重置 subscriptionUpdatedAt
595
+ subscriptionUpdatedAt.current = 0;
596
+ subscription.current.token = state.token;
597
+ subscription.current.on('updated', ({ response }) => {
598
+ const updatedAt = +new Date(response.updatedAt);
599
+ if (updatedAt <= subscriptionUpdatedAt.current) {
600
+ console.warn('Ignore outdated message', response);
601
+ return;
602
+ }
603
+
604
+ subscriptionUpdatedAt.current = updatedAt;
605
+ let { status, error } = response;
606
+
607
+ if (status === 'forbidden') {
608
+ error = translation.forbidden;
609
+ status = 'error';
610
+ }
611
+ state.status = status;
612
+ state.mfaCode = response.mfaCode || 0;
613
+ state.store = response;
614
+ state.error = error;
615
+ // ws 通道发现的错误,也需要 onError 回调
616
+ if (error) {
617
+ onError(new Error(error), state?.store, decrypt);
618
+ }
619
+ });
620
+ }
621
+ }
622
+ }, [state.token, currentState.isSocketAvailable, socket.current]); // eslint-disable-line react-hooks/exhaustive-deps
623
+
624
+ useInterval(checkStatus, getCheckInterval());
625
+
626
+ // Restore session status when page lost focus and get focus again
627
+ usePageShow(() => {
628
+ // HACK: 由于 safari 的特性,在页面不可见时,无法正常发起请求,所以需要在页面可见时强制发送一次请求来更新状态
629
+ if (state.token && isSessionEnded(state.status) === false) {
630
+ state.checking = false;
631
+ // HACK: 不能在页面可见时立刻发起请求,仍然可能会被 safari 拦截,需要等一段时间
632
+ setTimeout(() => {
633
+ checkStatus(true);
634
+ }, 100);
635
+ }
636
+ });
637
+
638
+ return { state, generate, cancelWhenScanned };
639
+ }