@lowdefy/shell 3.23.3 → 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.f01501a75909f38ccc32.js +0 -1
  356. package/dist/shell/runtime.e50f6c3a5f7c74a0a717.js +0 -1
@@ -0,0 +1,165 @@
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 createAppSession from '../session/createAppSession.js';
18
+ import createFakeServer, { header } from '../test/createFakeServer.js';
19
+ import createMemoryCacheStore from '../test/createMemoryCacheStore.js';
20
+ import createMemoryStore from './createMemoryStore.js';
21
+ import createShellFetch from './createShellFetch.js';
22
+ import { createPages, createRoot } from '../test/fixtures.js';
23
+
24
+ const apiBase = 'https://field.example.com';
25
+
26
+ async function setup() {
27
+ const server = createFakeServer({
28
+ root: createRoot(),
29
+ rootEtag: '"root-1"',
30
+ pages: createPages([
31
+ ['today', 'today', '"p-today-1"'],
32
+ ['jobs', 'jobs', '"p-jobs-1"'],
33
+ ['jobs/{job_id}', 'job', '"p-job-1"'],
34
+ ]),
35
+ requests: () => ({ id: 'get_jobs', success: true, response: [] }),
36
+ });
37
+ const session = createAppSession({
38
+ apiBase,
39
+ app: { id: 'field' },
40
+ cacheStore: createMemoryCacheStore(),
41
+ fetch: server.fetch,
42
+ kv: createMemoryStore(),
43
+ tokenStore: { get: () => null },
44
+ });
45
+ await session.start();
46
+ const shellFetch = createShellFetch({ fetch: server.fetch, session });
47
+ return { server, session, shellFetch };
48
+ }
49
+
50
+ function deploy({ server, todayHash }) {
51
+ server.state.buildId = 'build-2';
52
+ server.state.root = createRoot({
53
+ buildId: 'build-2',
54
+ pageIndex: [
55
+ { path: 'today', pageId: 'today', hash: todayHash, typesKey: 'carried' },
56
+ { path: 'jobs', pageId: 'jobs', hash: 'h-jobs-1', typesKey: 'carried' },
57
+ ],
58
+ });
59
+ server.state.rootEtag = '"root-2"';
60
+ }
61
+
62
+ const requestInit = {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json', 'x-lowdefy-build': 'build-1' },
65
+ body: '{}',
66
+ };
67
+
68
+ test('a page request carries the build of the root the shell holds', async () => {
69
+ const { server, shellFetch } = await setup();
70
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, {
71
+ ...requestInit,
72
+ headers: { ...requestInit.headers, 'x-lowdefy-build': 'stale-core-build' },
73
+ });
74
+ expect(res.status).toBe(200);
75
+ expect(header(server.fetch.mock.calls.at(-1)[1], 'x-lowdefy-build')).toBe('build-1');
76
+ });
77
+
78
+ test('a request sent with keepBuild keeps its build, so an older build gets the 409 and its retry', async () => {
79
+ const { server, shellFetch } = await setup();
80
+ deploy({ server, todayHash: 'h-today-1' });
81
+ server.fetch.mockClear();
82
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, {
83
+ ...requestInit,
84
+ headers: { ...requestInit.headers, 'x-lowdefy-build': 'build-0' },
85
+ keepBuild: true,
86
+ });
87
+ expect(res.status).toBe(200);
88
+ expect(header(server.fetch.mock.calls[0][1], 'x-lowdefy-build')).toBe('build-0');
89
+ expect(server.fetch.mock.calls[0][1].keepBuild).toBeUndefined();
90
+ expect(header(server.fetch.mock.calls.at(-1)[1], 'x-lowdefy-build')).toBe('build-2');
91
+ });
92
+
93
+ test('a 409 syncs the root and changed pages, then retries the request once on the new build', async () => {
94
+ const { server, session, shellFetch } = await setup();
95
+ deploy({ server, todayHash: 'h-today-2' });
96
+ server.state.pages.today = { pageConfig: { pageId: 'today', v: 2 }, etag: '"p-today-2"' };
97
+ server.fetch.mockClear();
98
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, requestInit);
99
+ expect(res.status).toBe(200);
100
+ expect(await res.json()).toEqual({ id: 'get_jobs', success: true, response: [] });
101
+ const urls = server.fetch.mock.calls.map(([url]) => new URL(url).pathname);
102
+ expect(urls).toEqual([
103
+ '/api/request/jobs/get_jobs',
104
+ '/api/mobile/root',
105
+ '/api/user',
106
+ '/api/page/today',
107
+ '/api/request/jobs/get_jobs',
108
+ ]);
109
+ expect(header(server.fetch.mock.calls.at(-1)[1], 'x-lowdefy-build')).toBe('build-2');
110
+ expect(session.getState().buildId).toBe('build-2');
111
+ expect(session.getState().pages.today.pageConfig.v).toBe(2);
112
+ });
113
+
114
+ test('a 409 for a page that changed with the build goes to the core, not retried', async () => {
115
+ const { server, session, shellFetch } = await setup();
116
+ deploy({ server, todayHash: 'h-today-2' });
117
+ server.fetch.mockClear();
118
+ const res = await shellFetch(`${apiBase}/api/request/today/get_jobs`, requestInit);
119
+ expect(res.status).toBe(409);
120
+ expect((await res.json()).buildId).toBe('build-2');
121
+ expect(server.fetch.mock.calls.filter(([url]) => url.includes('/api/request/'))).toHaveLength(1);
122
+ expect(session.getState().buildId).toBe('build-2');
123
+ });
124
+
125
+ test('a second 409 after the retry goes to the core', async () => {
126
+ const { server, shellFetch } = await setup();
127
+ deploy({ server, todayHash: 'h-today-1' });
128
+ const serve = server.fetch.getMockImplementation();
129
+ server.fetch.mockImplementation(async (url, init) => {
130
+ if (url.includes('/api/request/')) {
131
+ return {
132
+ ok: false,
133
+ status: 409,
134
+ headers: { get: () => null },
135
+ json: async () => ({ buildId: 'build-3' }),
136
+ };
137
+ }
138
+ return serve(url, init);
139
+ });
140
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, requestInit);
141
+ expect(res.status).toBe(409);
142
+ expect(server.fetch.mock.calls.filter(([url]) => url.includes('/api/request/'))).toHaveLength(2);
143
+ });
144
+
145
+ test('a 409 the shell cannot sync for goes to the core', async () => {
146
+ const { server, shellFetch } = await setup();
147
+ deploy({ server, todayHash: 'h-today-1' });
148
+ const serve = server.fetch.getMockImplementation();
149
+ server.fetch.mockImplementation(async (url, init) => {
150
+ if (url.includes('/api/mobile/root')) throw new TypeError('Network request failed');
151
+ return serve(url, init);
152
+ });
153
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, requestInit);
154
+ expect(res.status).toBe(409);
155
+ });
156
+
157
+ test('a call without a build stamp (an endpoint call) is never retried', async () => {
158
+ const { server, shellFetch } = await setup();
159
+ deploy({ server, todayHash: 'h-today-1' });
160
+ server.fetch.mockClear();
161
+ const res = await shellFetch(`${apiBase}/api/endpoints/save`, { method: 'POST', headers: {} });
162
+ expect(res.status).toBe(404);
163
+ expect(server.fetch).toHaveBeenCalledTimes(1);
164
+ expect(header(server.fetch.mock.calls[0][1], 'x-lowdefy-build')).toBeNull();
165
+ });
@@ -0,0 +1,62 @@
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 { createNativeGlobals } from '@lowdefy/client-native';
18
+
19
+ /**
20
+ * The host globals of one app in the shell: @lowdefy/client-native's (the shown route, window
21
+ * size, visibility, network and the pages' elements), and the device services the shell's native
22
+ * actions reach (src/actions):
23
+ * - `clipboard`: `{ setString(text) }`, for CopyToClipboard.
24
+ * - `storage`: the device store the core keeps the app's and its users' keys in
25
+ * (`{ get, set, remove, keys }`), for the local storage actions. Sign-out clears the user keys
26
+ * from it.
27
+ * - `shownScreen`: `{ instanceId }`, the route instance of the focused screen, whose page context
28
+ * is keyed by it (client-native LowdefyPage); Lowdefy Dev finds the shown page's context by it.
29
+ *
30
+ * @param {Object} params
31
+ * @param {(url: string) => void} params.assign
32
+ * @param {{ setString: (text: string) => Promise<void> }} params.clipboard
33
+ * @param {Function} params.fetch
34
+ * @param {() => (string|null)} params.getInstanceId The focused screen's route instance.
35
+ * @param {() => string} params.getPath
36
+ * @param {Object} [params.network]
37
+ * @param {string} params.origin
38
+ * @param {Object} params.storage
39
+ */
40
+ function createShellGlobals({
41
+ assign,
42
+ clipboard,
43
+ fetch,
44
+ getInstanceId,
45
+ getPath,
46
+ network,
47
+ origin,
48
+ storage,
49
+ }) {
50
+ return {
51
+ ...createNativeGlobals({ assign, fetch, getPath, network, origin }),
52
+ clipboard,
53
+ shownScreen: {
54
+ get instanceId() {
55
+ return getInstanceId();
56
+ },
57
+ },
58
+ storage,
59
+ };
60
+ }
61
+
62
+ export default createShellGlobals;
@@ -0,0 +1,83 @@
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 { Linking } from 'react-native';
18
+ import * as Clipboard from 'expo-clipboard';
19
+
20
+ import createDeviceStore from './createDeviceStore.js';
21
+ import createMemoryStore from './createMemoryStore.js';
22
+ import createShellGlobals from './createShellGlobals.js';
23
+ import getOrigin from './getOrigin.js';
24
+ import networkStatus from './networkStatus.js';
25
+
26
+ // The app's `online` and `offline` events follow the shell's one online state.
27
+ const network = {
28
+ addListener: networkStatus.subscribe,
29
+ };
30
+
31
+ /**
32
+ * The @lowdefy/client-core host for one app in the shell. Calls are signed in with the session
33
+ * token as a bearer token. The core's build and caller handling lands on the session: a page
34
+ * request refused for another build is retried by `fetch` (createShellFetch) or rebuilds the
35
+ * screens (onBuildChanged); another caller resets the app (onCallerChanged).
36
+ */
37
+ const clipboard = {
38
+ setString: (text) => Clipboard.setStringAsync(text),
39
+ };
40
+
41
+ function createShellHost({
42
+ fetch,
43
+ requestQueue,
44
+ router,
45
+ session,
46
+ streamFetch,
47
+ tokenStore,
48
+ WebSocket,
49
+ }) {
50
+ const storage = createDeviceStore();
51
+ return {
52
+ authTransport: 'bearer',
53
+ fetch,
54
+ globals: createShellGlobals({
55
+ assign: (url) => Linking.openURL(url),
56
+ clipboard,
57
+ fetch,
58
+ getInstanceId: session.getCurrentInstanceId,
59
+ getPath: session.getCurrentPath,
60
+ network,
61
+ origin: getOrigin(session.apiBase),
62
+ storage,
63
+ }),
64
+ onBuildChanged: () => {
65
+ session.handleBuildChanged();
66
+ },
67
+ onCallerChanged: () => {
68
+ session.handleCallerChanged();
69
+ },
70
+ // `offline: queue` requests go through it (src/queue/createRequestQueue.js).
71
+ requestQueue,
72
+ router,
73
+ sessionStorage: createMemoryStore(),
74
+ storage,
75
+ // The Fetch API whose response body streams, for blocks (client-native's createStreamFetch).
76
+ streamFetch,
77
+ tokenStore,
78
+ WebSocket,
79
+ websocketUrl: (apiPath) => apiPath.replace(/^http/, 'ws'),
80
+ };
81
+ }
82
+
83
+ export default createShellHost;
@@ -0,0 +1,36 @@
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 { jest } from '@jest/globals';
18
+
19
+ import createShellHost from './createShellHost.js';
20
+
21
+ jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(async () => true) }));
22
+ jest.mock('./createDeviceStore.js', () => () => ({}));
23
+ jest.mock('./networkStatus.js', () => ({ subscribe: () => () => undefined }));
24
+
25
+ // Copy link and the other CopyToClipboard actions reach the phone's clipboard through the host.
26
+ test('the host clipboard copies through expo-clipboard', async () => {
27
+ const Clipboard = jest.requireMock('expo-clipboard');
28
+ const session = {
29
+ apiBase: 'https://field.example.com',
30
+ getCurrentInstanceId: () => null,
31
+ getCurrentPath: () => '/',
32
+ };
33
+ const host = createShellHost({ fetch: jest.fn(), router: {}, session, tokenStore: {} });
34
+ await host.globals.clipboard.setString('https://field.example.com/tasks/12');
35
+ expect(Clipboard.setStringAsync.mock.calls).toEqual([['https://field.example.com/tasks/12']]);
36
+ });
@@ -0,0 +1,31 @@
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 { LowdefyInternalError } from '@lowdefy/errors';
18
+
19
+ // The origin of the app server's URL: its scheme, host and port. checkShellConfig refuses a
20
+ // shell.json url that is not absolute, so one reaching here is a Lowdefy wiring bug.
21
+ function getOrigin(url) {
22
+ const match = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]+/i.exec(url);
23
+ if (match === null) {
24
+ throw new LowdefyInternalError(
25
+ `Shell app url is not an absolute URL. Received ${JSON.stringify(url)}.`
26
+ );
27
+ }
28
+ return match[0];
29
+ }
30
+
31
+ export default getOrigin;
@@ -0,0 +1,24 @@
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
+ // A call that never reached the server: the platform's fetch rejected (no connection, a DNS or TLS
18
+ // failure, Lowdefy Dev's offline injection), which every shell call marks at the one fetch it goes
19
+ // through (withNetworkErrors). A TypeError thrown after a response arrived is a fault, not this.
20
+ function isNetworkError(error) {
21
+ return error?.isNetworkError === true;
22
+ }
23
+
24
+ export default isNetworkError;
@@ -0,0 +1,22 @@
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
+ // An expo-network state counts as offline only when it says so: unknown reachability is online.
18
+ function isOnline(networkState) {
19
+ return networkState.isConnected !== false && networkState.isInternetReachable !== false;
20
+ }
21
+
22
+ export default isOnline;
@@ -0,0 +1,24 @@
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 * as Network from 'expo-network';
18
+
19
+ import createNetworkStatus from './createNetworkStatus.js';
20
+
21
+ // The shell's one online state (createNetworkStatus), shared by every app the shell opens.
22
+ const networkStatus = createNetworkStatus({ network: Network });
23
+
24
+ export default networkStatus;
@@ -0,0 +1,45 @@
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 { decodeErrorBody } from '@lowdefy/client-core';
18
+ import { type } from '@lowdefy/helpers';
19
+
20
+ /**
21
+ * The error a non-2xx answer from the app's server carries, decoded as the core's requests decode
22
+ * it (decodeErrorBody): a fault keeps its class, `requestId`, `handled` mark and dev error, and an
23
+ * expected outcome (a 400, 401 or 403 the server answered as { name, message }) its class. Any other
24
+ * answer (a gateway's page, a library's own body) is a plain Error with the body's message, or
25
+ * `message`. Every error carries the HTTP `status`, so a caller can tell a refusal that will not
26
+ * change from an outage.
27
+ *
28
+ * @param {Object} params
29
+ * @param {Response} params.res The answer, not yet read.
30
+ * @param {string} params.message The message for an answer that carries none.
31
+ */
32
+ async function readServerError({ res, message }) {
33
+ let body = null;
34
+ try {
35
+ body = await res.json();
36
+ } catch (error) {
37
+ // Not JSON: a host or gateway answered before the app did.
38
+ }
39
+ const error =
40
+ decodeErrorBody(body) ?? new Error(type.isString(body?.message) ? body.message : message);
41
+ error.status = res.status;
42
+ return error;
43
+ }
44
+
45
+ export default readServerError;
@@ -0,0 +1,85 @@
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 { getDevError } from '@lowdefy/engine';
18
+ import { AuthorizationError, RequestError } from '@lowdefy/errors';
19
+
20
+ import readServerError from './readServerError.js';
21
+
22
+ function answer({ status, body, json = async () => body }) {
23
+ return { ok: false, status, json };
24
+ }
25
+
26
+ test('a fault the server answered keeps its class, request id, handled mark and dev error', async () => {
27
+ const error = await readServerError({
28
+ res: answer({
29
+ status: 500,
30
+ body: {
31
+ '~e': {
32
+ name: 'RequestError',
33
+ message: 'Something went wrong.',
34
+ requestId: 'rid-1',
35
+ handled: true,
36
+ isLowdefyError: true,
37
+ },
38
+ devError: {
39
+ '~e': { name: 'RequestError', message: 'Mongo refused the query.', isLowdefyError: true },
40
+ },
41
+ },
42
+ }),
43
+ message: 'The app answered 500.',
44
+ });
45
+ expect(error).toBeInstanceOf(RequestError);
46
+ expect(error.message).toBe('Something went wrong.');
47
+ expect(error.requestId).toBe('rid-1');
48
+ expect(error.handled).toBe(true);
49
+ expect(error.status).toBe(500);
50
+ expect(getDevError(error).message).toBe('Mongo refused the query.');
51
+ });
52
+
53
+ test('an expected outcome the server answered as name and message keeps its class', async () => {
54
+ const error = await readServerError({
55
+ res: answer({ status: 403, body: { name: 'AuthorizationError', message: 'Not allowed.' } }),
56
+ message: 'The app answered 403.',
57
+ });
58
+ expect(error).toBeInstanceOf(AuthorizationError);
59
+ expect(error.message).toBe('Not allowed.');
60
+ expect(error.status).toBe(403);
61
+ });
62
+
63
+ test('another JSON answer is a plain Error with its message and the status', async () => {
64
+ const error = await readServerError({
65
+ res: answer({ status: 400, body: { code: 'INVALID_OTP', message: 'Invalid code.' } }),
66
+ message: 'The app answered 400.',
67
+ });
68
+ expect(error.constructor).toBe(Error);
69
+ expect(error.message).toBe('Invalid code.');
70
+ expect(error.status).toBe(400);
71
+ });
72
+
73
+ test('an answer that is not JSON is a plain Error with the given message and the status', async () => {
74
+ const error = await readServerError({
75
+ res: answer({
76
+ status: 502,
77
+ json: async () => {
78
+ throw new SyntaxError('Unexpected token <');
79
+ },
80
+ }),
81
+ message: 'The app answered 502.',
82
+ });
83
+ expect(error.message).toBe('The app answered 502.');
84
+ expect(error.status).toBe(502);
85
+ });
@@ -0,0 +1,47 @@
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 { isExpectedError } from '@lowdefy/errors';
18
+
19
+ import isNetworkError from './isNetworkError.js';
20
+ import { peekCurrentApp } from '../native/currentApp.js';
21
+
22
+ /**
23
+ * The shell's sink for a fault no screen shows or that a developer must know of: a failed sync, a
24
+ * push token the server refused, a background task that threw. It goes to the core's handleError
25
+ * once a page has created one (the device log, Lowdefy Dev's error bar, and for a Lowdefy error the
26
+ * server log), and to the device console before then. Expected outcomes are not faults: no
27
+ * network, or a refusal the server answered as one (UserError, AuthenticationError, ...), are
28
+ * dropped.
29
+ *
30
+ * @param {Object} params
31
+ * @param {Error} params.error
32
+ * @param {{ getState }} [params.session] The app session whose core reports it; the open app's by
33
+ * default (currentApp.js).
34
+ */
35
+ function reportShellError({ error, session = peekCurrentApp()?.session }) {
36
+ if (isNetworkError(error) || isExpectedError(error)) {
37
+ return;
38
+ }
39
+ const handleError = session?.getState().lowdefy?._internal?.handleError;
40
+ if (handleError === undefined) {
41
+ console.error(error);
42
+ return;
43
+ }
44
+ Promise.resolve(handleError(error)).catch(() => console.error(error));
45
+ }
46
+
47
+ export default reportShellError;