@lowdefy/shell 3.23.2 → 7.2.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 (356) hide show
  1. package/LICENSE +183 -183
  2. package/README.md +364 -2
  3. package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
  4. package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
  5. package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
  6. package/app/+native-intent.js +39 -0
  7. package/app/_layout.js +36 -0
  8. package/app.config.ts +183 -0
  9. package/appLinks.js +65 -0
  10. package/applyShellPlugins.js +43 -0
  11. package/authCallbackIntentFilters.js +46 -0
  12. package/babel.config.js +25 -0
  13. package/checkShellConfig.js +172 -0
  14. package/checkShellPlugins.js +74 -0
  15. package/getPrivacyManifest.js +39 -0
  16. package/metro.config.js +73 -0
  17. package/native/LowdefyAppShortcuts.swift.template +84 -0
  18. package/native/withAppShortcuts.js +60 -0
  19. package/native-fingerprint.json +16 -0
  20. package/package.json +120 -40
  21. package/profiles.js +607 -0
  22. package/readShellConfig.js +36 -0
  23. package/resolveShellPlugins.js +109 -0
  24. package/runtimeVersion.js +112 -0
  25. package/scripts/checkReleaseBundle.mjs +358 -0
  26. package/scripts/createDayjsLocales.mjs +67 -0
  27. package/scripts/createShellAssets.mjs +54 -0
  28. package/scripts/createShellVersion.mjs +83 -0
  29. package/scripts/createTypeManifest.mjs +205 -0
  30. package/shell.dev.json +11 -0
  31. package/shellVariant.js +126 -0
  32. package/src/ShellContext.js +32 -0
  33. package/src/actions/CopyToClipboard.js +29 -0
  34. package/src/actions/CopyToClipboard.test.js +33 -0
  35. package/src/actions/GeolocationCurrentPosition.js +61 -0
  36. package/src/actions/GeolocationCurrentPosition.test.js +79 -0
  37. package/src/actions/GetLocalStorage.js +36 -0
  38. package/src/actions/RemoveLocalStorage.js +31 -0
  39. package/src/actions/ScrollTo.js +66 -0
  40. package/src/actions/ScrollTo.test.js +83 -0
  41. package/src/actions/SetFocus.js +33 -0
  42. package/src/actions/SetFocus.test.js +49 -0
  43. package/src/actions/SetLocalStorage.js +34 -0
  44. package/src/actions/localStorage.test.js +68 -0
  45. package/src/actions/measureInScrollView.js +36 -0
  46. package/src/actions/nativeActions.js +41 -0
  47. package/src/auth/AuthButton.js +44 -0
  48. package/src/auth/AuthGate.js +57 -0
  49. package/src/auth/AuthLink.js +43 -0
  50. package/src/auth/LockScreen.js +67 -0
  51. package/src/auth/LockScreen.test.js +37 -0
  52. package/src/auth/SignInScreen.js +268 -0
  53. package/src/auth/SignInScreen.test.js +187 -0
  54. package/src/auth/createAppAuth.js +325 -0
  55. package/src/auth/createAppAuth.test.js +424 -0
  56. package/src/auth/createAuthApi.js +115 -0
  57. package/src/auth/createAuthApi.test.js +138 -0
  58. package/src/auth/createCoreAuth.js +96 -0
  59. package/src/auth/createCoreAuth.test.js +188 -0
  60. package/src/auth/createDeviceKey.js +62 -0
  61. package/src/auth/createDeviceKey.test.js +54 -0
  62. package/src/auth/createSessionStores.js +45 -0
  63. package/src/auth/createTokenStore.js +103 -0
  64. package/src/auth/createTokenStore.test.js +116 -0
  65. package/src/auth/getAuthSettings.js +24 -0
  66. package/src/auth/getAuthView.js +39 -0
  67. package/src/auth/getAuthView.test.js +52 -0
  68. package/src/auth/getSheetReturnUrl.js +35 -0
  69. package/src/auth/getTokenStoreOptions.js +46 -0
  70. package/src/auth/isBackgroundShell.js +26 -0
  71. package/src/auth/isSheetReturnLink.js +35 -0
  72. package/src/auth/parseQuery.js +39 -0
  73. package/src/auth/purgeAppData.js +80 -0
  74. package/src/auth/purgeAppData.test.js +104 -0
  75. package/src/auth/shouldLock.js +26 -0
  76. package/src/auth/signInWithSheet.js +97 -0
  77. package/src/auth/signInWithSheet.test.js +205 -0
  78. package/src/auth/styles.js +78 -0
  79. package/src/auth/unregisterPushToken.js +62 -0
  80. package/src/auth/unregisterPushToken.test.js +67 -0
  81. package/src/auth/usesBiometricUnlock.js +23 -0
  82. package/src/auth/watchSession.js +47 -0
  83. package/src/auth/withoutCookies.js +26 -0
  84. package/src/cache/createSqliteCacheStore.js +113 -0
  85. package/src/cache/createSqliteCacheStore.test.js +93 -0
  86. package/src/cache/getCacheKey.js +25 -0
  87. package/src/cache/getLastCacheKeyName.js +22 -0
  88. package/src/components/AppProvider.js +118 -0
  89. package/src/components/Frame.js +116 -0
  90. package/src/components/Frame.test.js +101 -0
  91. package/src/components/LoadingScreen.js +35 -0
  92. package/src/components/MenuButton.js +121 -0
  93. package/src/components/MenuButton.test.js +74 -0
  94. package/src/components/MessageScreen.js +51 -0
  95. package/src/components/NavigationTheme.js +33 -0
  96. package/src/components/NoConnectionBanner.js +51 -0
  97. package/src/components/NoConnectionBanner.test.js +73 -0
  98. package/src/components/OtherPageScreen.js +95 -0
  99. package/src/components/OtherPageScreen.test.js +74 -0
  100. package/src/components/PageScreen.js +254 -0
  101. package/src/components/PageScreen.test.js +492 -0
  102. package/src/components/RouteErrorScreen.js +47 -0
  103. package/src/components/ShellRoot.js +34 -0
  104. package/src/components/ShellRoot.test.js +62 -0
  105. package/src/components/ShellThemeProvider.js +45 -0
  106. package/src/components/SwitchToTab.js +36 -0
  107. package/src/components/SwitchToTab.test.js +63 -0
  108. package/src/components/TabIconImages.js +72 -0
  109. package/src/components/UpdateScreen.js +40 -0
  110. package/src/components/getNavigationTheme.js +44 -0
  111. package/src/components/getNavigationTheme.test.js +41 -0
  112. package/src/components/styles.js +50 -0
  113. package/src/components/useSurfaceTokens.js +47 -0
  114. package/src/config/getAppApiBase.js +34 -0
  115. package/src/config/getShellConfig.js +37 -0
  116. package/src/config/getShellConfig.test.js +80 -0
  117. package/src/createShellApp.js +126 -0
  118. package/src/dev/DevShell.js +239 -0
  119. package/src/dev/DevShell.test.js +233 -0
  120. package/src/dev/connect/ConfirmSheet.js +71 -0
  121. package/src/dev/connect/ConnectScreen.js +117 -0
  122. package/src/dev/connect/QrScanner.js +89 -0
  123. package/src/dev/connection/createDevShellConfig.js +47 -0
  124. package/src/dev/connection/createDevTransport.js +51 -0
  125. package/src/dev/connection/createDevTransport.test.js +87 -0
  126. package/src/dev/connection/createServerList.js +48 -0
  127. package/src/dev/connection/isLoopbackHost.js +25 -0
  128. package/src/dev/connection/pairedShellApp.test.js +104 -0
  129. package/src/dev/connection/parseDevLink.js +66 -0
  130. package/src/dev/connection/parseDevLink.test.js +97 -0
  131. package/src/dev/connection/parseQuery.js +33 -0
  132. package/src/dev/connection/parseServerInput.js +40 -0
  133. package/src/dev/connection/parseServerUrl.js +45 -0
  134. package/src/dev/devStyles.js +97 -0
  135. package/src/dev/index.js +21 -0
  136. package/src/dev/inject/createDeviceInjections.js +188 -0
  137. package/src/dev/inject/createDeviceInjections.test.js +224 -0
  138. package/src/dev/inject/createOfflineFetch.js +35 -0
  139. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  140. package/src/dev/inject/injectPush.js +60 -0
  141. package/src/dev/inject/injectShare.js +55 -0
  142. package/src/dev/inject/withInjectedDevice.js +98 -0
  143. package/src/dev/menu/BlockGallery.js +33 -0
  144. package/src/dev/menu/DevMenu.js +121 -0
  145. package/src/dev/menu/createLogBuffer.js +83 -0
  146. package/src/dev/menu/watchShake.js +52 -0
  147. package/src/dev/menu/watchShake.test.js +61 -0
  148. package/src/dev/recorder/createNativeRecorder.js +204 -0
  149. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  150. package/src/dev/redirectDevLink.js +30 -0
  151. package/src/dev/redirectDevLink.test.js +49 -0
  152. package/src/dev/steps/createStepRunner.js +184 -0
  153. package/src/dev/steps/createStepRunner.test.js +262 -0
  154. package/src/dev/steps/findBlock.js +78 -0
  155. package/src/dev/steps/isContextSettled.js +37 -0
  156. package/src/dev/steps/waitForSettle.js +39 -0
  157. package/src/dev/steps/waitUntil.js +40 -0
  158. package/src/dev/tab/DevTabClient.js +97 -0
  159. package/src/dev/tab/answerEval.js +39 -0
  160. package/src/dev/tab/answerInspect.js +45 -0
  161. package/src/dev/tab/createDevTabClient.js +272 -0
  162. package/src/dev/tab/createDevTabClient.test.js +317 -0
  163. package/src/dev/tab/describeTabError.js +35 -0
  164. package/src/dev/tab/devClientMarker.js +22 -0
  165. package/src/dev/tab/devClientMarker.test.js +23 -0
  166. package/src/dev/tab/findPageContext.js +43 -0
  167. package/src/dev/tab/getShownPage.js +42 -0
  168. package/src/dev/tab/readEventStream.js +68 -0
  169. package/src/dev/tab/reloadApp.js +42 -0
  170. package/src/dev/tab/reloadApp.test.js +115 -0
  171. package/src/device/DeviceHosts.js +32 -0
  172. package/src/device/basic/createFilesFeature.js +41 -0
  173. package/src/device/basic/createHapticsFeature.js +46 -0
  174. package/src/device/basic/createKeepAwakeFeature.js +37 -0
  175. package/src/device/basic/createPhotosFeature.js +40 -0
  176. package/src/device/basic/createRequestQueueFeature.js +37 -0
  177. package/src/device/basic/createShareFeature.js +63 -0
  178. package/src/device/basic/createShareFeature.test.js +47 -0
  179. package/src/device/basic/createSpeechFeature.js +43 -0
  180. package/src/device/basic/createUploadFeature.js +41 -0
  181. package/src/device/basic/toImagePickerOptions.js +41 -0
  182. package/src/device/basic/toPickedFile.js +54 -0
  183. package/src/device/calendar/index.js +68 -0
  184. package/src/device/contacts/index.js +67 -0
  185. package/src/device/createDevice.js +65 -0
  186. package/src/device/device.js +27 -0
  187. package/src/device/field/MapView.js +93 -0
  188. package/src/device/field/ScanCodeSheet.js +86 -0
  189. package/src/device/field/ScannerView.js +52 -0
  190. package/src/device/field/createAudioFeature.js +70 -0
  191. package/src/device/field/createBrightnessFeature.js +40 -0
  192. package/src/device/field/createCameraFeature.js +56 -0
  193. package/src/device/field/createDocumentScannerFeature.js +51 -0
  194. package/src/device/field/createLocationFeature.js +39 -0
  195. package/src/device/field/createScanRequests.js +55 -0
  196. package/src/device/field/index.js +39 -0
  197. package/src/device/field/scanRequests.js +22 -0
  198. package/src/device/injected.js +60 -0
  199. package/src/device/injected.test.js +39 -0
  200. package/src/device/nfc/index.js +89 -0
  201. package/src/device/nfc/toNfcRecord.js +44 -0
  202. package/src/device/nfc/toNfcRecord.test.js +48 -0
  203. package/src/device/tracking/createBacklog.js +63 -0
  204. package/src/device/tracking/createBacklog.test.js +47 -0
  205. package/src/device/tracking/index.js +211 -0
  206. package/src/device/tracking/index.test.js +110 -0
  207. package/src/host/createDeviceStore.js +32 -0
  208. package/src/host/createMemoryStore.js +32 -0
  209. package/src/host/createNetworkStatus.js +94 -0
  210. package/src/host/createNetworkStatus.test.js +94 -0
  211. package/src/host/createRouterAdapter.js +33 -0
  212. package/src/host/createShellFetch.js +62 -0
  213. package/src/host/createShellFetch.test.js +165 -0
  214. package/src/host/createShellGlobals.js +62 -0
  215. package/src/host/createShellHost.js +83 -0
  216. package/src/host/createShellHost.test.js +36 -0
  217. package/src/host/getOrigin.js +31 -0
  218. package/src/host/isNetworkError.js +24 -0
  219. package/src/host/isOnline.js +22 -0
  220. package/src/host/networkStatus.js +24 -0
  221. package/src/host/readServerError.js +45 -0
  222. package/src/host/readServerError.test.js +85 -0
  223. package/src/host/reportShellError.js +47 -0
  224. package/src/host/reportShellError.test.js +79 -0
  225. package/src/host/withNetworkErrors.js +38 -0
  226. package/src/host/withNetworkErrors.test.js +61 -0
  227. package/src/host/withShellHeader.js +25 -0
  228. package/src/host/withShellHeader.test.js +28 -0
  229. package/src/js/evaluateJsEntries.js +43 -0
  230. package/src/js/evaluateJsEntries.test.js +48 -0
  231. package/src/locale/createShellLocale.js +112 -0
  232. package/src/locale/createShellLocale.test.js +74 -0
  233. package/src/locale/dayjsLocales.js +165 -0
  234. package/src/locale/getDeviceLocales.js +23 -0
  235. package/src/locale/loadDayjsLocale.js +32 -0
  236. package/src/locale/resolveActiveLocale.js +46 -0
  237. package/src/locale/resolveActiveLocale.test.js +40 -0
  238. package/src/native/backgroundFetch.js +97 -0
  239. package/src/native/callEndpoint.js +63 -0
  240. package/src/native/createAppEventTrigger.js +70 -0
  241. package/src/native/currentApp.js +49 -0
  242. package/src/native/getPagePath.js +32 -0
  243. package/src/native/openPath.js +55 -0
  244. package/src/native/openPath.test.js +39 -0
  245. package/src/native/parseShellLink.js +38 -0
  246. package/src/native/parseShellLink.test.js +29 -0
  247. package/src/native/startAppServices.js +94 -0
  248. package/src/native/startAppServices.test.js +97 -0
  249. package/src/native/startShareIn.js +59 -0
  250. package/src/native/startShortcuts.js +88 -0
  251. package/src/native/startShortcuts.test.js +59 -0
  252. package/src/native/toSharePayload.js +47 -0
  253. package/src/native/toSharePayload.test.js +47 -0
  254. package/src/native/whenAppReady.js +48 -0
  255. package/src/navigation/SlotContext.js +24 -0
  256. package/src/navigation/frameSlots.js +27 -0
  257. package/src/navigation/getFrame.js +30 -0
  258. package/src/navigation/getInitialScreenOptions.js +63 -0
  259. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  260. package/src/navigation/getPresentation.js +28 -0
  261. package/src/navigation/getSlotRootPath.js +27 -0
  262. package/src/navigation/getStackOptions.js +185 -0
  263. package/src/navigation/getTabHref.js +45 -0
  264. package/src/navigation/getTabIcons.js +43 -0
  265. package/src/navigation/isPageUnderHeader.js +46 -0
  266. package/src/navigation/navigation.test.js +342 -0
  267. package/src/navigation/toAppLinkPath.js +43 -0
  268. package/src/navigation/toAppLinkPath.test.js +53 -0
  269. package/src/navigation/toRouterHref.js +29 -0
  270. package/src/notifications/createNotificationsFeature.js +61 -0
  271. package/src/notifications/getCategoryAction.js +31 -0
  272. package/src/notifications/handleNotificationResponse.js +82 -0
  273. package/src/notifications/registerPushToken.js +71 -0
  274. package/src/notifications/startNotifications.js +114 -0
  275. package/src/notifications/toCategoryActions.js +42 -0
  276. package/src/notifications/toNotificationEvent.js +36 -0
  277. package/src/notifications/toScheduleRequest.js +62 -0
  278. package/src/plugins/appTypes.js +28 -0
  279. package/src/plugins/prefixTypes.js +28 -0
  280. package/src/plugins/shellPlugins.json +15 -0
  281. package/src/plugins/typeManifest.js +789 -0
  282. package/src/plugins/types.js +64 -0
  283. package/src/plugins/types.test.js +173 -0
  284. package/src/polyfills/installGetRandomValues.js +28 -0
  285. package/src/queue/createRequestQueue.js +198 -0
  286. package/src/queue/createRequestQueue.test.js +215 -0
  287. package/src/queue/createSqliteQueueStore.js +121 -0
  288. package/src/queue/summarizeQueue.js +39 -0
  289. package/src/session/createAppSession.js +323 -0
  290. package/src/session/createAppSession.test.js +391 -0
  291. package/src/session/getPageView.js +59 -0
  292. package/src/session/getPageView.test.js +137 -0
  293. package/src/sync/encodePagePath.js +27 -0
  294. package/src/sync/fetchPage.js +72 -0
  295. package/src/sync/fetchPage.test.js +84 -0
  296. package/src/sync/fetchRoot.js +43 -0
  297. package/src/sync/fetchUser.js +33 -0
  298. package/src/sync/runLimited.js +36 -0
  299. package/src/sync/toRootConfig.js +35 -0
  300. package/src/test/createFakeNetwork.js +40 -0
  301. package/src/test/createFakeSecureStore.js +40 -0
  302. package/src/test/createFakeServer.js +98 -0
  303. package/src/test/createMemoryCacheStore.js +59 -0
  304. package/src/test/createNetworkError.js +24 -0
  305. package/src/test/expoImage.js +42 -0
  306. package/src/test/expoNotifications.js +40 -0
  307. package/src/test/expoQuickActions.js +22 -0
  308. package/src/test/expoSpeechRecognition.js +31 -0
  309. package/src/test/expoTaskManager.js +25 -0
  310. package/src/test/expoUi.js +55 -0
  311. package/src/test/expoUiBottomSheet.js +64 -0
  312. package/src/test/expoUiDateTimePicker.js +26 -0
  313. package/src/test/expoUiMenu.js +46 -0
  314. package/src/test/expoUiSegmentedControl.js +47 -0
  315. package/src/test/expoUiSwiftUi.js +62 -0
  316. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  317. package/src/test/expoWebBrowser.js +23 -0
  318. package/src/test/fixtures.js +98 -0
  319. package/src/test/nativeModule.js +33 -0
  320. package/src/test/renderPageScreen.js +109 -0
  321. package/src/types/checkTypeSet.js +39 -0
  322. package/src/types/collectPageTypes.js +49 -0
  323. package/src/types/collectPageTypes.test.js +90 -0
  324. package/src/types/findMissingPageTypes.js +40 -0
  325. package/src/version/compareVersions.js +38 -0
  326. package/src/version/isShellTooOld.js +30 -0
  327. package/src/version/shellVersion.js +21 -0
  328. package/src/version/shellVersion.test.js +32 -0
  329. package/src/widgets/fetchWidgetData.js +59 -0
  330. package/src/widgets/index.js +156 -0
  331. package/src/widgets/refreshWidgets.js +31 -0
  332. package/withExcludedNativeModules.js +93 -0
  333. package/dist/location.js +0 -33
  334. package/dist/public/apple-touch-icon.png +0 -0
  335. package/dist/public/icon-32.png +0 -0
  336. package/dist/public/icon-512.png +0 -0
  337. package/dist/public/icon.svg +0 -17
  338. package/dist/public/logo-dark-theme.png +0 -0
  339. package/dist/public/logo-light-theme.png +0 -0
  340. package/dist/public/logo-square-dark-theme.png +0 -0
  341. package/dist/public/logo-square-light-theme.png +0 -0
  342. package/dist/public/manifest.webmanifest +0 -16
  343. package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
  344. package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
  345. package/dist/shell/454.b597ca21366afda000af.js +0 -2
  346. package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
  347. package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
  348. package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
  349. package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
  350. package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
  351. package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
  352. package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
  353. package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
  354. package/dist/shell/index.html +0 -57
  355. package/dist/shell/main.bde87d1433f0625faed6.js +0 -1
  356. package/dist/shell/runtime.1ead93159ba56c05b969.js +0 -1
@@ -0,0 +1,239 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
+ import { Linking, LogBox, View } from 'react-native';
19
+ import * as SplashScreen from 'expo-splash-screen';
20
+ import { Accelerometer } from 'expo-sensors';
21
+ import { installDevice } from '@lowdefy/blocks-native/device';
22
+ import { installDictationSource } from '@lowdefy/blocks-native/dictation';
23
+
24
+ import AppProvider from '../components/AppProvider.js';
25
+ import ConfirmSheet from './connect/ConfirmSheet.js';
26
+ import ConnectScreen from './connect/ConnectScreen.js';
27
+ import createDevShellConfig from './connection/createDevShellConfig.js';
28
+ import createDeviceInjections from './inject/createDeviceInjections.js';
29
+ import createDeviceStore from '../host/createDeviceStore.js';
30
+ import createLogBuffer from './menu/createLogBuffer.js';
31
+ import createOfflineFetch from './inject/createOfflineFetch.js';
32
+ import createServerList, { SERVERS_KEY } from './connection/createServerList.js';
33
+ import DevMenu from './menu/DevMenu.js';
34
+ import DevTabClient from './tab/DevTabClient.js';
35
+ import Frame from '../components/Frame.js';
36
+ import getShellConfig from '../config/getShellConfig.js';
37
+ import device from '../device/device.js';
38
+ import injected, { installInjector } from '../device/injected.js';
39
+ import withInjectedDevice from './inject/withInjectedDevice.js';
40
+ import isLoopbackHost from './connection/isLoopbackHost.js';
41
+ import networkStatus from '../host/networkStatus.js';
42
+ import parseDevLink from './connection/parseDevLink.js';
43
+ import reloadApp from './tab/reloadApp.js';
44
+ import watchShake from './menu/watchShake.js';
45
+
46
+ // Lowdefy Dev reports errors in its own log (the dev menu) and to the dev server's build status.
47
+ // React Native's LogBox toasts would cover the tab bar and every screenshot an agent takes.
48
+ LogBox.ignoreAllLogs();
49
+
50
+ /**
51
+ * Lowdefy Dev: the shell with developer features, in place of ShellRoot. It opens one app, the
52
+ * dev server it is connected to:
53
+ * - It connects to the last server it used, to a server a `lowdefy-dev://connect` link names, or
54
+ * to one picked on the connect screen (a scanned code, a typed address, a recent server). It asks
55
+ * "Connect to <host>?" first for any server not on this device: a simulator's or USB phone's
56
+ * localhost connects at once, which is how `lowdefy dev --ios` and `--android` open it.
57
+ * - Shaking the phone (or a `lowdefy-dev://menu` link) opens the dev menu.
58
+ * - The open app joins the dev server's tab channel (DevTabClient), which records what is done in
59
+ * it and takes device injections: the next scan, photo or NFC read, what the next dictation
60
+ * session hears, a location, a permission answer, or no network, which fails the app's every
61
+ * fetch and shows the NoConnection banner (createDeviceInjections).
62
+ *
63
+ * @param {Object} props
64
+ * @param {Object} [props.accelerometer] expo-sensors' Accelerometer.
65
+ * @param {Object} [props.kv] The device store.
66
+ * @param {Object} [props.linking] React Native's Linking.
67
+ * @param {Object} [props.shell] The shell config.
68
+ */
69
+ function DevShell({
70
+ accelerometer = Accelerometer,
71
+ kv: kvProp,
72
+ linking = Linking,
73
+ shell: shellProp,
74
+ }) {
75
+ const shell = useMemo(() => shellProp ?? getShellConfig(), [shellProp]);
76
+ const kv = useMemo(() => kvProp ?? createDeviceStore(), [kvProp]);
77
+ const servers = useMemo(() => createServerList({ kv }), [kv]);
78
+ const logs = useMemo(() => createLogBuffer({ console: globalThis.console }), []);
79
+ const injections = useMemo(() => createDeviceInjections({ networkStatus }), []);
80
+ const rootRef = useRef(null);
81
+ const openApp = useRef(null);
82
+ const [server, setServer] = useState(() => servers.last());
83
+ const [offered, setOffered] = useState(null);
84
+ const [message, setMessage] = useState(null);
85
+ const [menuOpen, setMenuOpen] = useState(false);
86
+ const [openings, setOpenings] = useState(0);
87
+
88
+ const connect = useCallback(
89
+ (target) => {
90
+ servers.remember(target);
91
+ setOffered(null);
92
+ setMessage(null);
93
+ setServer({ url: target.url, host: target.host, pair: target.pair ?? null });
94
+ },
95
+ [servers]
96
+ );
97
+
98
+ const offer = useCallback(
99
+ (target) => {
100
+ if (isLoopbackHost(target.host)) {
101
+ connect(target);
102
+ return;
103
+ }
104
+ setOffered(target);
105
+ },
106
+ [connect]
107
+ );
108
+
109
+ const handleLink = useCallback(
110
+ (link) => {
111
+ const parsed = parseDevLink({ link, scheme: shell.scheme });
112
+ if (parsed === null) return false;
113
+ if (parsed.kind === 'menu') setMenuOpen(true);
114
+ if (parsed.kind === 'invalid') setMessage(parsed.reason);
115
+ if (parsed.kind === 'connect') offer(parsed);
116
+ return true;
117
+ },
118
+ [offer, shell.scheme]
119
+ );
120
+
121
+ useEffect(() => logs.install(), [logs]);
122
+
123
+ useEffect(() => installInjector(injections), [injections]);
124
+
125
+ // The native inputs' dictation plays injected transcripts, and uses the microphone otherwise.
126
+ useEffect(() => installDictationSource(injected), []);
127
+
128
+ // ScanCode, QRScanner, PickImage, TakePhoto and ReadNfc answer injected results first.
129
+ useEffect(() => {
130
+ installDevice(withInjectedDevice({ features: device.features, read: injected }));
131
+ return () => {
132
+ installDevice(device.features);
133
+ };
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ linking.getInitialURL().then((link) => link && handleLink(link));
138
+ const subscription = linking.addEventListener('url', ({ url }) => handleLink(url));
139
+ return () => subscription.remove();
140
+ }, [handleLink, linking]);
141
+
142
+ useEffect(() => watchShake({ accelerometer, onShake: () => setMenuOpen(true) }), [accelerometer]);
143
+
144
+ useEffect(() => {
145
+ if (server === null) SplashScreen.hideAsync();
146
+ }, [server]);
147
+
148
+ const devShell = useMemo(
149
+ () =>
150
+ server === null
151
+ ? null
152
+ : createDevShellConfig({
153
+ fetch: createOfflineFetch({
154
+ fetch: globalThis.fetch,
155
+ isOffline: injections.isOffline,
156
+ }),
157
+ server,
158
+ shell,
159
+ }),
160
+ [injections, server, shell]
161
+ );
162
+
163
+ const onApp = useCallback((app) => {
164
+ openApp.current = app;
165
+ }, []);
166
+
167
+ // An app that could not open is opened again.
168
+ function reload() {
169
+ if (openApp.current === null) {
170
+ setOpenings((count) => count + 1);
171
+ return;
172
+ }
173
+ reloadApp({ session: openApp.current.session }).catch((error) => logs.add('error', error));
174
+ }
175
+
176
+ // Signs out, which removes the session token and every cached page of this server's app, then
177
+ // removes the rest of what the device store holds for apps (keeping Lowdefy Dev's server list)
178
+ // and opens the app afresh.
179
+ async function clearData() {
180
+ if (openApp.current !== null) {
181
+ await openApp.current.auth.signOut();
182
+ }
183
+ kv.keys()
184
+ .filter((key) => key !== SERVERS_KEY)
185
+ .forEach((key) => kv.remove(key));
186
+ setOpenings((count) => count + 1);
187
+ }
188
+
189
+ const sheet = (
190
+ <ConfirmSheet
191
+ onCancel={() => setOffered(null)}
192
+ onConfirm={() => connect(offered)}
193
+ server={offered}
194
+ />
195
+ );
196
+ const menu = (
197
+ <DevMenu
198
+ logs={logs}
199
+ onClearData={clearData}
200
+ onClose={() => setMenuOpen(false)}
201
+ onReload={reload}
202
+ onSwitchServer={() => setServer(null)}
203
+ server={server?.host ?? null}
204
+ shell={shell}
205
+ visible={menuOpen}
206
+ />
207
+ );
208
+
209
+ if (devShell === null) {
210
+ return (
211
+ <>
212
+ <ConnectScreen
213
+ message={message}
214
+ onOffer={offer}
215
+ onPick={connect}
216
+ onScanned={(text) => {
217
+ if (!handleLink(text)) setMessage('That code is not one lowdefy dev printed.');
218
+ }}
219
+ recent={servers.list()}
220
+ scheme={shell.scheme}
221
+ />
222
+ {sheet}
223
+ {menu}
224
+ </>
225
+ );
226
+ }
227
+ return (
228
+ <View collapsable={false} ref={rootRef} style={{ flex: 1 }}>
229
+ <AppProvider key={`${devShell.app.id}:${openings}`} shell={devShell}>
230
+ <Frame />
231
+ <DevTabClient injections={injections} kv={kv} logs={logs} onApp={onApp} rootRef={rootRef} />
232
+ </AppProvider>
233
+ {sheet}
234
+ {menu}
235
+ </View>
236
+ );
237
+ }
238
+
239
+ export default DevShell;
@@ -0,0 +1,233 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React from 'react';
18
+ import { jest } from '@jest/globals';
19
+ import { act, fireEvent, render, screen } from '@testing-library/react-native';
20
+
21
+ import createMemoryStore from '../host/createMemoryStore.js';
22
+ import DevShell from './DevShell.js';
23
+
24
+ // The opened app is recorded, not built: its auth, session and screens have their own tests.
25
+ const mockOpened = [];
26
+ jest.mock('../components/AppProvider.js', () => {
27
+ function AppProvider({ children, shell }) {
28
+ const { app } = shell;
29
+ mockOpened.push({ app, shell });
30
+ const { Text: MockText } = jest.requireActual('react-native');
31
+ return (
32
+ <>
33
+ <MockText testID="opened-app">{app.urls.dev}</MockText>
34
+ {children}
35
+ </>
36
+ );
37
+ }
38
+ return AppProvider;
39
+ });
40
+ jest.mock('../components/Frame.js', () => () => null);
41
+ jest.mock('./tab/DevTabClient.js', () => () => null);
42
+ jest.mock('../host/createDeviceStore.js', () => () => {
43
+ throw new Error('The tests pass a device store.');
44
+ });
45
+ jest.mock('../config/getShellConfig.js', () => () => {
46
+ throw new Error('The tests pass the shell config.');
47
+ });
48
+ jest.mock('expo-splash-screen', () => ({ hideAsync: jest.fn() }));
49
+ jest.mock('expo-network', () => ({
50
+ addNetworkStateListener: () => ({ remove: () => undefined }),
51
+ getNetworkStateAsync: async () => ({ isConnected: true, isInternetReachable: true }),
52
+ }));
53
+ jest.mock('expo-sensors', () => ({ Accelerometer: {} }));
54
+ jest.mock('expo-camera', () => ({
55
+ CameraView: () => null,
56
+ useCameraPermissions: () => [{ granted: true }, jest.fn()],
57
+ }));
58
+ jest.mock('react-native-safe-area-context', () => ({
59
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
60
+ }));
61
+
62
+ const shell = {
63
+ id: 'lowdefy-dev',
64
+ name: 'Lowdefy Dev',
65
+ environment: 'dev',
66
+ scheme: 'lowdefy-dev',
67
+ version: '7.0.0',
68
+ app: { id: 'dev', name: 'Lowdefy Dev', urls: { dev: 'http://localhost:3000' } },
69
+ };
70
+
71
+ function createLinking(initialUrl = null) {
72
+ const listeners = new Set();
73
+ return {
74
+ getInitialURL: jest.fn(async () => initialUrl),
75
+ addEventListener: (event, listener) => {
76
+ listeners.add(listener);
77
+ return { remove: () => listeners.delete(listener) };
78
+ },
79
+ open: (url) => listeners.forEach((listener) => listener({ url })),
80
+ };
81
+ }
82
+
83
+ function createAccelerometer() {
84
+ let listener = null;
85
+ return {
86
+ setUpdateInterval: jest.fn(),
87
+ addListener: (fn) => {
88
+ listener = fn;
89
+ return { remove: () => undefined };
90
+ },
91
+ shake: () => [0, 1, 2].forEach(() => listener({ x: 2, y: 2, z: 2 })),
92
+ };
93
+ }
94
+
95
+ async function renderDevShell({ initialUrl = null, kv = createMemoryStore() } = {}) {
96
+ const linking = createLinking(initialUrl);
97
+ const accelerometer = createAccelerometer();
98
+ await render(<DevShell accelerometer={accelerometer} kv={kv} linking={linking} shell={shell} />);
99
+ await act(async () => {});
100
+ return { accelerometer, kv, linking };
101
+ }
102
+
103
+ const lanLink = 'lowdefy-dev://connect?url=http%3A%2F%2F192.168.1.20%3A3000&pair=k3x9q';
104
+
105
+ beforeEach(() => {
106
+ mockOpened.splice(0);
107
+ });
108
+
109
+ test('with no server, Lowdefy Dev shows the connect screen', async () => {
110
+ await renderDevShell();
111
+ expect(screen.getByTestId('dev-connect')).toBeTruthy();
112
+ expect(screen.queryByTestId('opened-app')).toBeNull();
113
+ });
114
+
115
+ test('a connect link asks "Connect to <host>?" and attaches only once confirmed', async () => {
116
+ const { kv } = await renderDevShell({ initialUrl: lanLink });
117
+ expect(screen.getByText('Connect to 192.168.1.20:3000?')).toBeTruthy();
118
+ expect(screen.queryByTestId('opened-app')).toBeNull();
119
+ await act(async () => {
120
+ fireEvent.press(screen.getByTestId('dev-confirm-connect'));
121
+ });
122
+ expect(screen.getByTestId('opened-app').props.children).toBe('http://192.168.1.20:3000');
123
+ expect(JSON.parse(kv.get('lowdefy-dev:servers'))).toEqual([
124
+ { url: 'http://192.168.1.20:3000', host: '192.168.1.20:3000', pair: 'k3x9q' },
125
+ ]);
126
+ });
127
+
128
+ test('the opened app sends the pairing token on every call', async () => {
129
+ await renderDevShell({ initialUrl: lanLink });
130
+ await act(async () => {
131
+ fireEvent.press(screen.getByTestId('dev-confirm-connect'));
132
+ });
133
+ const { app, shell: devShell } = mockOpened.at(-1);
134
+ expect(app.id).toBe('dev-192.168.1.20_3000');
135
+ const fetch = jest.fn(async () => ({ status: 200 }));
136
+ const pairedFetch = devShell.transport.wrapFetch(fetch);
137
+ await pairedFetch('http://192.168.1.20:3000/api/mobile/root');
138
+ expect(fetch.mock.calls[0][1].headers).toEqual({ 'x-lowdefy-pair': 'k3x9q' });
139
+ });
140
+
141
+ test('a cancelled link attaches to nothing', async () => {
142
+ const { kv } = await renderDevShell({ initialUrl: lanLink });
143
+ await act(async () => {
144
+ fireEvent.press(screen.getByTestId('dev-confirm-cancel'));
145
+ });
146
+ expect(screen.queryByText('Connect to 192.168.1.20:3000?')).toBeNull();
147
+ expect(screen.queryByTestId('opened-app')).toBeNull();
148
+ expect(kv.get('lowdefy-dev:servers')).toBeNull();
149
+ });
150
+
151
+ test('a typed address is confirmed before it is attached to', async () => {
152
+ await renderDevShell();
153
+ await act(async () => {
154
+ fireEvent.changeText(screen.getByTestId('dev-connect-url'), '192.168.1.20:3000');
155
+ });
156
+ await act(async () => {
157
+ fireEvent.press(screen.getByTestId('dev-connect-submit'));
158
+ });
159
+ expect(screen.getByText('Connect to 192.168.1.20:3000?')).toBeTruthy();
160
+ expect(screen.queryByTestId('opened-app')).toBeNull();
161
+ });
162
+
163
+ test('a simulator link to localhost connects at once', async () => {
164
+ await renderDevShell({
165
+ initialUrl: 'lowdefy-dev://connect?url=http%3A%2F%2Flocalhost%3A3000',
166
+ });
167
+ expect(screen.queryByTestId('dev-confirm-sheet')).toBeNull();
168
+ expect(screen.getByTestId('opened-app').props.children).toBe('http://localhost:3000');
169
+ });
170
+
171
+ test('the last server opens at launch, and a link to another one is confirmed first', async () => {
172
+ const kv = createMemoryStore();
173
+ kv.set(
174
+ 'lowdefy-dev:servers',
175
+ JSON.stringify([{ url: 'http://10.0.0.5:3000', host: '10.0.0.5:3000', pair: 'old' }])
176
+ );
177
+ const { linking } = await renderDevShell({ kv });
178
+ expect(screen.getByTestId('opened-app').props.children).toBe('http://10.0.0.5:3000');
179
+ await act(async () => {
180
+ linking.open(lanLink);
181
+ });
182
+ expect(screen.getByText('Connect to 192.168.1.20:3000?')).toBeTruthy();
183
+ expect(screen.getByTestId('opened-app').props.children).toBe('http://10.0.0.5:3000');
184
+ });
185
+
186
+ test('shaking the phone opens the dev menu, with the shell version', async () => {
187
+ const { accelerometer } = await renderDevShell({
188
+ initialUrl: 'lowdefy-dev://connect?url=http%3A%2F%2Flocalhost%3A3000',
189
+ });
190
+ expect(screen.queryByTestId('dev-menu')).toBeNull();
191
+ await act(async () => {
192
+ accelerometer.shake();
193
+ });
194
+ expect(screen.getByTestId('dev-menu')).toBeTruthy();
195
+ expect(screen.getByTestId('dev-menu-version').props.children).toBe(
196
+ 'Shell 7.0.0 · localhost:3000'
197
+ );
198
+ });
199
+
200
+ test('a menu link opens the dev menu, and switch server goes back to the connect screen', async () => {
201
+ const { linking } = await renderDevShell({
202
+ initialUrl: 'lowdefy-dev://connect?url=http%3A%2F%2Flocalhost%3A3000',
203
+ });
204
+ await act(async () => {
205
+ linking.open('lowdefy-dev://menu');
206
+ });
207
+ await act(async () => {
208
+ fireEvent.press(screen.getByTestId('dev-menu-switch'));
209
+ });
210
+ expect(screen.getByTestId('dev-connect')).toBeTruthy();
211
+ expect(screen.getByTestId('dev-recent-localhost:3000')).toBeTruthy();
212
+ });
213
+
214
+ test('the dev menu shows the shell logs and the block gallery entry', async () => {
215
+ jest.spyOn(console, 'info').mockImplementation(() => undefined);
216
+ const { linking } = await renderDevShell();
217
+ await act(async () => {
218
+ linking.open('lowdefy-dev://menu');
219
+ });
220
+ await act(async () => {
221
+ console.info('Dev tab: joined the dev server as tab phone.');
222
+ fireEvent.press(screen.getByTestId('dev-menu-logs'));
223
+ await new Promise((resolve) => setTimeout(resolve, 10));
224
+ });
225
+ expect(screen.getByText(/Dev tab: joined the dev server as tab phone\./)).toBeTruthy();
226
+ await act(async () => {
227
+ fireEvent.press(screen.getByTestId('dev-menu-close'));
228
+ });
229
+ await act(async () => {
230
+ fireEvent.press(screen.getByTestId('dev-menu-gallery'));
231
+ });
232
+ expect(screen.getByTestId('dev-block-gallery')).toBeTruthy();
233
+ });
@@ -0,0 +1,71 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React from 'react';
18
+ import { Modal, Pressable, Text, View } from 'react-native';
19
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
20
+
21
+ import devStyles from '../devStyles.js';
22
+
23
+ /**
24
+ * "Connect to <host>?", asked before Lowdefy Dev attaches to a server from a link, a scanned code
25
+ * or a typed address: a link can come from anyone, and the server it names sends every page the
26
+ * app shows.
27
+ *
28
+ * @param {Object} props
29
+ * @param {{ url, host, pair }|null} props.server The server to ask about; null hides the sheet.
30
+ * @param {() => void} props.onConfirm
31
+ * @param {() => void} props.onCancel
32
+ */
33
+ function ConfirmSheet({ onCancel, onConfirm, server }) {
34
+ const insets = useSafeAreaInsets();
35
+ return (
36
+ <Modal animationType="slide" onRequestClose={onCancel} transparent visible={server !== null}>
37
+ <View style={devStyles.backdrop}>
38
+ <View
39
+ style={[devStyles.sheet, { paddingBottom: insets.bottom + 24 }]}
40
+ testID="dev-confirm-sheet"
41
+ >
42
+ <Text style={devStyles.sheetTitle}>{`Connect to ${server?.host}?`}</Text>
43
+ <Text style={devStyles.hint}>{server?.url}</Text>
44
+ <Text style={devStyles.hint}>
45
+ {server?.pair
46
+ ? 'Paired with lowdefy dev --lan. Connect only to a server you started.'
47
+ : 'Connect only to a server you started.'}
48
+ </Text>
49
+ <Pressable
50
+ accessibilityRole="button"
51
+ onPress={onConfirm}
52
+ style={devStyles.button}
53
+ testID="dev-confirm-connect"
54
+ >
55
+ <Text style={devStyles.buttonText}>Connect</Text>
56
+ </Pressable>
57
+ <Pressable
58
+ accessibilityRole="button"
59
+ onPress={onCancel}
60
+ style={devStyles.secondaryButton}
61
+ testID="dev-confirm-cancel"
62
+ >
63
+ <Text style={devStyles.secondaryButtonText}>Cancel</Text>
64
+ </Pressable>
65
+ </View>
66
+ </View>
67
+ </Modal>
68
+ );
69
+ }
70
+
71
+ export default ConfirmSheet;
@@ -0,0 +1,117 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React, { useState } from 'react';
18
+ import { Pressable, ScrollView, Text, TextInput } from 'react-native';
19
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
20
+
21
+ import devStyles from '../devStyles.js';
22
+ import parseServerInput from '../connection/parseServerInput.js';
23
+ import QrScanner from './QrScanner.js';
24
+
25
+ /**
26
+ * Lowdefy Dev with no server: scan the code `lowdefy dev --lan` prints, type or paste its address,
27
+ * or pick one of the last 5 servers.
28
+ *
29
+ * @param {Object} props
30
+ * @param {Array<{ url, host, pair }>} props.recent The servers connected to before, last first.
31
+ * @param {(server) => void} props.onOffer A server to confirm, from a code or a typed address.
32
+ * @param {(server) => void} props.onPick A server connected to before.
33
+ * @param {(text: string) => void} props.onScanned The text of a scanned code.
34
+ * @param {string} props.scheme The shell's URL scheme.
35
+ * @param {string|null} props.message A note to show, such as why a link could not be used.
36
+ */
37
+ function ConnectScreen({ message, onOffer, onPick, onScanned, recent, scheme }) {
38
+ const insets = useSafeAreaInsets();
39
+ const [input, setInput] = useState('');
40
+ const [error, setError] = useState(null);
41
+ const [scanning, setScanning] = useState(false);
42
+
43
+ function submit() {
44
+ const parsed = parseServerInput({ input, scheme });
45
+ if (parsed.error) {
46
+ setError(parsed.error);
47
+ return;
48
+ }
49
+ setError(null);
50
+ onOffer(parsed);
51
+ }
52
+
53
+ return (
54
+ <ScrollView
55
+ contentContainerStyle={[devStyles.screen, { paddingTop: insets.top + 24 }]}
56
+ keyboardShouldPersistTaps="handled"
57
+ testID="dev-connect"
58
+ >
59
+ <Text style={devStyles.heading}>Lowdefy Dev</Text>
60
+ <Text style={devStyles.hint}>
61
+ Run lowdefy dev --lan and scan the code it prints, or enter the address it shows.
62
+ </Text>
63
+ {message ? <Text style={devStyles.error}>{message}</Text> : null}
64
+ <Pressable
65
+ accessibilityRole="button"
66
+ onPress={() => setScanning(true)}
67
+ style={devStyles.button}
68
+ testID="dev-connect-scan"
69
+ >
70
+ <Text style={devStyles.buttonText}>Scan code</Text>
71
+ </Pressable>
72
+ <TextInput
73
+ autoCapitalize="none"
74
+ autoCorrect={false}
75
+ inputMode="url"
76
+ onChangeText={setInput}
77
+ onSubmitEditing={submit}
78
+ placeholder="http://192.168.1.20:3000"
79
+ style={devStyles.input}
80
+ testID="dev-connect-url"
81
+ value={input}
82
+ />
83
+ {error ? <Text style={devStyles.error}>{error}</Text> : null}
84
+ <Pressable
85
+ accessibilityRole="button"
86
+ onPress={submit}
87
+ style={devStyles.secondaryButton}
88
+ testID="dev-connect-submit"
89
+ >
90
+ <Text style={devStyles.secondaryButtonText}>Connect</Text>
91
+ </Pressable>
92
+ {recent.length > 0 ? <Text style={devStyles.hint}>Recent servers</Text> : null}
93
+ {recent.map((server) => (
94
+ <Pressable
95
+ accessibilityRole="button"
96
+ key={server.url}
97
+ onPress={() => onPick(server)}
98
+ style={devStyles.row}
99
+ testID={`dev-recent-${server.host}`}
100
+ >
101
+ <Text style={devStyles.rowText}>{server.host}</Text>
102
+ <Text style={devStyles.hint}>{server.url}</Text>
103
+ </Pressable>
104
+ ))}
105
+ <QrScanner
106
+ onClose={() => setScanning(false)}
107
+ onScanned={(text) => {
108
+ setScanning(false);
109
+ onScanned(text);
110
+ }}
111
+ visible={scanning}
112
+ />
113
+ </ScrollView>
114
+ );
115
+ }
116
+
117
+ export default ConnectScreen;