@lowdefy/shell 0.0.0-experimental-20261019052246

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 (239) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +308 -0
  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 +30 -0
  7. package/app/_layout.js +33 -0
  8. package/app.config.ts +168 -0
  9. package/applyShellPlugins.js +43 -0
  10. package/authCallbackIntentFilters.js +46 -0
  11. package/babel.config.js +25 -0
  12. package/checkShellConfig.js +145 -0
  13. package/checkShellPlugins.js +74 -0
  14. package/getPrivacyManifest.js +39 -0
  15. package/metro.config.js +73 -0
  16. package/native-fingerprint.json +12 -0
  17. package/package.json +130 -0
  18. package/profiles.js +274 -0
  19. package/readShellConfig.js +36 -0
  20. package/resolveShellPlugins.js +109 -0
  21. package/runtimeVersion.js +105 -0
  22. package/scripts/checkReleaseBundle.mjs +358 -0
  23. package/scripts/createDayjsLocales.mjs +67 -0
  24. package/scripts/createShellAssets.mjs +54 -0
  25. package/scripts/createShellVersion.mjs +83 -0
  26. package/scripts/createTypeManifest.mjs +205 -0
  27. package/shell.dev.json +11 -0
  28. package/shellVariant.js +111 -0
  29. package/src/ShellContext.js +32 -0
  30. package/src/actions/CopyToClipboard.js +29 -0
  31. package/src/actions/CopyToClipboard.test.js +33 -0
  32. package/src/actions/GeolocationCurrentPosition.js +55 -0
  33. package/src/actions/GeolocationCurrentPosition.test.js +66 -0
  34. package/src/actions/GetLocalStorage.js +36 -0
  35. package/src/actions/RemoveLocalStorage.js +31 -0
  36. package/src/actions/ScrollTo.js +66 -0
  37. package/src/actions/ScrollTo.test.js +83 -0
  38. package/src/actions/SetFocus.js +33 -0
  39. package/src/actions/SetFocus.test.js +49 -0
  40. package/src/actions/SetLocalStorage.js +34 -0
  41. package/src/actions/localStorage.test.js +68 -0
  42. package/src/actions/measureInScrollView.js +36 -0
  43. package/src/actions/nativeActions.js +41 -0
  44. package/src/auth/AuthButton.js +44 -0
  45. package/src/auth/AuthGate.js +57 -0
  46. package/src/auth/AuthLink.js +43 -0
  47. package/src/auth/LockScreen.js +67 -0
  48. package/src/auth/LockScreen.test.js +37 -0
  49. package/src/auth/SignInScreen.js +256 -0
  50. package/src/auth/SignInScreen.test.js +182 -0
  51. package/src/auth/createAppAuth.js +306 -0
  52. package/src/auth/createAppAuth.test.js +414 -0
  53. package/src/auth/createAuthApi.js +99 -0
  54. package/src/auth/createAuthApi.test.js +135 -0
  55. package/src/auth/createCoreAuth.js +96 -0
  56. package/src/auth/createCoreAuth.test.js +188 -0
  57. package/src/auth/createSessionStores.js +45 -0
  58. package/src/auth/createTokenStore.js +103 -0
  59. package/src/auth/createTokenStore.test.js +116 -0
  60. package/src/auth/getAuthSettings.js +24 -0
  61. package/src/auth/getAuthView.js +39 -0
  62. package/src/auth/getAuthView.test.js +52 -0
  63. package/src/auth/getSheetReturnUrl.js +35 -0
  64. package/src/auth/getTokenStoreOptions.js +46 -0
  65. package/src/auth/isBackgroundShell.js +26 -0
  66. package/src/auth/isSheetReturnLink.js +35 -0
  67. package/src/auth/parseQuery.js +39 -0
  68. package/src/auth/purgeAppData.js +45 -0
  69. package/src/auth/shouldLock.js +26 -0
  70. package/src/auth/signInWithSheet.js +97 -0
  71. package/src/auth/signInWithSheet.test.js +205 -0
  72. package/src/auth/styles.js +78 -0
  73. package/src/auth/unregisterPushToken.js +21 -0
  74. package/src/auth/usesBiometricUnlock.js +23 -0
  75. package/src/auth/watchSession.js +47 -0
  76. package/src/auth/withoutCookies.js +26 -0
  77. package/src/cache/createSqliteCacheStore.js +113 -0
  78. package/src/cache/createSqliteCacheStore.test.js +93 -0
  79. package/src/cache/getCacheKey.js +25 -0
  80. package/src/cache/getLastCacheKeyName.js +22 -0
  81. package/src/components/AppProvider.js +90 -0
  82. package/src/components/Frame.js +116 -0
  83. package/src/components/Frame.test.js +101 -0
  84. package/src/components/LoadingScreen.js +35 -0
  85. package/src/components/MenuButton.js +108 -0
  86. package/src/components/MenuButton.test.js +51 -0
  87. package/src/components/MessageScreen.js +51 -0
  88. package/src/components/NavigationTheme.js +33 -0
  89. package/src/components/NoConnectionBanner.js +51 -0
  90. package/src/components/NoConnectionBanner.test.js +73 -0
  91. package/src/components/OtherPageScreen.js +56 -0
  92. package/src/components/PageScreen.js +244 -0
  93. package/src/components/PageScreen.test.js +492 -0
  94. package/src/components/ShellRoot.js +34 -0
  95. package/src/components/ShellRoot.test.js +62 -0
  96. package/src/components/ShellThemeProvider.js +45 -0
  97. package/src/components/SwitchToTab.js +36 -0
  98. package/src/components/SwitchToTab.test.js +63 -0
  99. package/src/components/TabIconImages.js +72 -0
  100. package/src/components/UpdateScreen.js +36 -0
  101. package/src/components/getNavigationTheme.js +44 -0
  102. package/src/components/getNavigationTheme.test.js +41 -0
  103. package/src/components/styles.js +50 -0
  104. package/src/components/useSurfaceTokens.js +47 -0
  105. package/src/config/getAppApiBase.js +31 -0
  106. package/src/config/getShellConfig.js +35 -0
  107. package/src/config/getShellConfig.test.js +80 -0
  108. package/src/createShellApp.js +105 -0
  109. package/src/dev/DevShell.js +228 -0
  110. package/src/dev/DevShell.test.js +233 -0
  111. package/src/dev/connect/ConfirmSheet.js +71 -0
  112. package/src/dev/connect/ConnectScreen.js +117 -0
  113. package/src/dev/connect/QrScanner.js +89 -0
  114. package/src/dev/connection/createDevShellConfig.js +47 -0
  115. package/src/dev/connection/createDevTransport.js +51 -0
  116. package/src/dev/connection/createDevTransport.test.js +87 -0
  117. package/src/dev/connection/createServerList.js +48 -0
  118. package/src/dev/connection/isLoopbackHost.js +25 -0
  119. package/src/dev/connection/pairedShellApp.test.js +104 -0
  120. package/src/dev/connection/parseDevLink.js +66 -0
  121. package/src/dev/connection/parseDevLink.test.js +97 -0
  122. package/src/dev/connection/parseQuery.js +33 -0
  123. package/src/dev/connection/parseServerInput.js +40 -0
  124. package/src/dev/connection/parseServerUrl.js +45 -0
  125. package/src/dev/devStyles.js +97 -0
  126. package/src/dev/index.js +21 -0
  127. package/src/dev/inject/createDeviceInjections.js +185 -0
  128. package/src/dev/inject/createDeviceInjections.test.js +208 -0
  129. package/src/dev/inject/createOfflineFetch.js +35 -0
  130. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  131. package/src/dev/menu/BlockGallery.js +33 -0
  132. package/src/dev/menu/DevMenu.js +121 -0
  133. package/src/dev/menu/createLogBuffer.js +83 -0
  134. package/src/dev/menu/watchShake.js +52 -0
  135. package/src/dev/menu/watchShake.test.js +61 -0
  136. package/src/dev/recorder/createNativeRecorder.js +204 -0
  137. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  138. package/src/dev/redirectDevLink.js +30 -0
  139. package/src/dev/redirectDevLink.test.js +49 -0
  140. package/src/dev/steps/createStepRunner.js +184 -0
  141. package/src/dev/steps/createStepRunner.test.js +262 -0
  142. package/src/dev/steps/findBlock.js +78 -0
  143. package/src/dev/steps/isContextSettled.js +37 -0
  144. package/src/dev/steps/waitForSettle.js +39 -0
  145. package/src/dev/steps/waitUntil.js +40 -0
  146. package/src/dev/tab/DevTabClient.js +97 -0
  147. package/src/dev/tab/answerEval.js +39 -0
  148. package/src/dev/tab/answerInspect.js +45 -0
  149. package/src/dev/tab/answerLoadState.js +50 -0
  150. package/src/dev/tab/createDevTabClient.js +277 -0
  151. package/src/dev/tab/createDevTabClient.test.js +314 -0
  152. package/src/dev/tab/devClientMarker.js +22 -0
  153. package/src/dev/tab/devClientMarker.test.js +23 -0
  154. package/src/dev/tab/findPageContext.js +43 -0
  155. package/src/dev/tab/getShownPage.js +42 -0
  156. package/src/dev/tab/readEventStream.js +68 -0
  157. package/src/dev/tab/reloadApp.js +42 -0
  158. package/src/dev/tab/reloadApp.test.js +115 -0
  159. package/src/device/injected.js +60 -0
  160. package/src/device/injected.test.js +39 -0
  161. package/src/host/createDeviceStore.js +32 -0
  162. package/src/host/createMemoryStore.js +32 -0
  163. package/src/host/createNetworkStatus.js +94 -0
  164. package/src/host/createNetworkStatus.test.js +94 -0
  165. package/src/host/createRouterAdapter.js +33 -0
  166. package/src/host/createShellFetch.js +60 -0
  167. package/src/host/createShellFetch.test.js +150 -0
  168. package/src/host/createShellGlobals.js +62 -0
  169. package/src/host/createShellHost.js +73 -0
  170. package/src/host/createShellHost.test.js +36 -0
  171. package/src/host/getOrigin.js +26 -0
  172. package/src/host/isOnline.js +22 -0
  173. package/src/host/networkStatus.js +24 -0
  174. package/src/host/withShellHeader.js +25 -0
  175. package/src/host/withShellHeader.test.js +28 -0
  176. package/src/js/evaluateJsEntries.js +43 -0
  177. package/src/js/evaluateJsEntries.test.js +48 -0
  178. package/src/locale/createShellLocale.js +112 -0
  179. package/src/locale/createShellLocale.test.js +74 -0
  180. package/src/locale/dayjsLocales.js +165 -0
  181. package/src/locale/getDeviceLocales.js +23 -0
  182. package/src/locale/loadDayjsLocale.js +32 -0
  183. package/src/locale/resolveActiveLocale.js +46 -0
  184. package/src/locale/resolveActiveLocale.test.js +40 -0
  185. package/src/navigation/SlotContext.js +24 -0
  186. package/src/navigation/frameSlots.js +27 -0
  187. package/src/navigation/getFrame.js +30 -0
  188. package/src/navigation/getInitialScreenOptions.js +63 -0
  189. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  190. package/src/navigation/getPresentation.js +28 -0
  191. package/src/navigation/getSlotRootPath.js +27 -0
  192. package/src/navigation/getStackOptions.js +185 -0
  193. package/src/navigation/getTabHref.js +45 -0
  194. package/src/navigation/getTabIcons.js +43 -0
  195. package/src/navigation/isPageUnderHeader.js +46 -0
  196. package/src/navigation/navigation.test.js +342 -0
  197. package/src/navigation/toRouterHref.js +29 -0
  198. package/src/plugins/appTypes.js +28 -0
  199. package/src/plugins/prefixTypes.js +28 -0
  200. package/src/plugins/shellPlugins.json +14 -0
  201. package/src/plugins/typeManifest.js +649 -0
  202. package/src/plugins/types.js +61 -0
  203. package/src/plugins/types.test.js +173 -0
  204. package/src/polyfills/installGetRandomValues.js +28 -0
  205. package/src/session/createAppSession.js +318 -0
  206. package/src/session/createAppSession.test.js +359 -0
  207. package/src/session/getPageView.js +59 -0
  208. package/src/session/getPageView.test.js +137 -0
  209. package/src/sync/encodePagePath.js +27 -0
  210. package/src/sync/fetchPage.js +49 -0
  211. package/src/sync/fetchRoot.js +38 -0
  212. package/src/sync/fetchUser.js +28 -0
  213. package/src/sync/runLimited.js +36 -0
  214. package/src/sync/toRootConfig.js +35 -0
  215. package/src/test/createFakeNetwork.js +40 -0
  216. package/src/test/createFakeSecureStore.js +40 -0
  217. package/src/test/createFakeServer.js +98 -0
  218. package/src/test/createMemoryCacheStore.js +59 -0
  219. package/src/test/expoImage.js +38 -0
  220. package/src/test/expoSpeechRecognition.js +31 -0
  221. package/src/test/expoUi.js +55 -0
  222. package/src/test/expoUiBottomSheet.js +64 -0
  223. package/src/test/expoUiDateTimePicker.js +26 -0
  224. package/src/test/expoUiMenu.js +46 -0
  225. package/src/test/expoUiSegmentedControl.js +47 -0
  226. package/src/test/expoUiSwiftUi.js +62 -0
  227. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  228. package/src/test/expoWebBrowser.js +23 -0
  229. package/src/test/fixtures.js +98 -0
  230. package/src/test/renderPageScreen.js +109 -0
  231. package/src/types/checkTypeSet.js +39 -0
  232. package/src/types/collectPageTypes.js +49 -0
  233. package/src/types/collectPageTypes.test.js +90 -0
  234. package/src/types/findMissingPageTypes.js +40 -0
  235. package/src/version/compareVersions.js +38 -0
  236. package/src/version/isShellTooOld.js +30 -0
  237. package/src/version/shellVersion.js +21 -0
  238. package/src/version/shellVersion.test.js +32 -0
  239. package/withExcludedNativeModules.js +93 -0
@@ -0,0 +1,35 @@
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
+ /**
18
+ * Where the sign-in sheet returns to the shell with its one-time token: on iOS the shell's scheme,
19
+ * <scheme>://auth, which ASWebAuthenticationSession hands only to the app that opened the sheet;
20
+ * on Android the app's https app link, <app url>/auth/mobile/callback, which Android opens only in
21
+ * the shell the app's assetlinks.json names (app.config.ts declares it, authCallbackIntentFilters).
22
+ *
23
+ * @param {Object} params
24
+ * @param {string} params.apiBase The app server's URL.
25
+ * @param {'ios'|'android'} params.platform
26
+ * @param {{ scheme }} params.shell The shell config.
27
+ */
28
+ function getSheetReturnUrl({ apiBase, platform, shell }) {
29
+ if (platform === 'android') {
30
+ return `${apiBase.replace(/\/$/, '')}/auth/mobile/callback`;
31
+ }
32
+ return `${shell.scheme}://auth`;
33
+ }
34
+
35
+ export default getSheetReturnUrl;
@@ -0,0 +1,46 @@
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 SecureStore from 'expo-secure-store';
18
+
19
+ /**
20
+ * How the session token is kept in the Keychain or Keystore: on this device only, never in a
21
+ * backup.
22
+ *
23
+ * - A shell with background work (location reports while the phone is locked) keeps it readable
24
+ * after the phone's first unlock, so its background task can call the server. Biometrics then
25
+ * guard the screens only.
26
+ * - Any other shell keeps it readable only while the phone is unlocked, and, with biometric
27
+ * unlock, behind Face ID or a fingerprint: reading it is the unlock prompt. A change to the
28
+ * phone's biometrics makes it unreadable, and the person signs in again.
29
+ *
30
+ * A protected item is read and deleted with the same requireAuthentication it was written with.
31
+ */
32
+ function getTokenStoreOptions({ background, biometric }) {
33
+ if (background) {
34
+ return { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY };
35
+ }
36
+ if (biometric) {
37
+ return {
38
+ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
39
+ requireAuthentication: true,
40
+ authenticationPrompt: 'Unlock to continue',
41
+ };
42
+ }
43
+ return { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
44
+ }
45
+
46
+ export default getTokenStoreOptions;
@@ -0,0 +1,26 @@
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
+ // The shell profile that reports location in the background (shell.json `profile`). Its
18
+ // background task calls the server while the phone is locked, so its session token must stay
19
+ // readable then, and biometrics guard only its screens.
20
+ const BACKGROUND_PROFILES = ['field-tracking'];
21
+
22
+ function isBackgroundShell(shell) {
23
+ return BACKGROUND_PROFILES.includes(shell.profile);
24
+ }
25
+
26
+ export default isBackgroundShell;
@@ -0,0 +1,35 @@
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
+ // The link without its query or fragment. React Native's URL has no working pathname, so the
18
+ // link is cut by hand, as parseQuery reads its query.
19
+ function stripQuery(link) {
20
+ const end = link.search(/[?#]/);
21
+ return end === -1 ? link : link.slice(0, end);
22
+ }
23
+
24
+ /**
25
+ * Whether a link the router is opening is the sign-in sheet's return: the Android app link
26
+ * <app url>/auth/mobile/callback (getSheetReturnUrl). signInWithSheet reads it from the sheet's
27
+ * result, so the router must not open it as a page: its query holds the one-time token.
28
+ *
29
+ * @param {string} link The URL, or the path, Expo Router received.
30
+ */
31
+ function isSheetReturnLink(link) {
32
+ return /^(https:\/\/[^/]+)?(\/.*)?\/auth\/mobile\/callback\/?$/.test(stripQuery(link));
33
+ }
34
+
35
+ export default isSheetReturnLink;
@@ -0,0 +1,39 @@
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
+ // The query of a URL as { name: value }. React Native's URL has no working searchParams, and the
18
+ // sheet's redirect URL is a custom scheme.
19
+ function parseQuery(url) {
20
+ const start = url.indexOf('?');
21
+ if (start === -1) {
22
+ return {};
23
+ }
24
+ const end = url.indexOf('#', start);
25
+ const query = url.slice(start + 1, end === -1 ? undefined : end);
26
+ const params = {};
27
+ query
28
+ .split('&')
29
+ .filter((part) => part.length > 0)
30
+ .forEach((part) => {
31
+ const separator = part.indexOf('=');
32
+ const name = separator === -1 ? part : part.slice(0, separator);
33
+ const value = separator === -1 ? '' : part.slice(separator + 1);
34
+ params[decodeURIComponent(name)] = decodeURIComponent(value.replace(/\+/g, ' '));
35
+ });
36
+ return params;
37
+ }
38
+
39
+ export default parseQuery;
@@ -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 { appStorageKey } from '@lowdefy/helpers';
18
+
19
+ import getLastCacheKeyName from '../cache/getLastCacheKeyName.js';
20
+
21
+ /**
22
+ * Removes everything the shell keeps for one app's signed-in person: the session token, every
23
+ * cached root and page of the app (all its callers), the last cache key, and the person's keys in
24
+ * the device store (SetLocalStorage's user keys, which the core puts under one prefix per app).
25
+ * Device preferences stay. Nothing a person saw outlives their sign-out.
26
+ *
27
+ * @param {Object} params
28
+ * @param {string} params.appId The app.
29
+ * @param {Object} params.cacheStore The page cache.
30
+ * @param {{ keys, remove }} params.kv The device store.
31
+ * @param {string|null} params.slug The app's slug (lowdefyApp.slug), which names its storage keys.
32
+ * @param {{ clear }} params.tokenStore The session token store.
33
+ */
34
+ async function purgeAppData({ appId, cacheStore, kv, slug, tokenStore }) {
35
+ await tokenStore.clear();
36
+ await cacheStore.removeApp({ appId });
37
+ kv.remove(getLastCacheKeyName(appId));
38
+ // The shell renders every page with basePath '' (PageScreen).
39
+ const userPrefix = appStorageKey({ basePath: '', name: 'lowdefy_user:', slug });
40
+ kv.keys()
41
+ .filter((key) => key.startsWith(userPrefix))
42
+ .forEach((key) => kv.remove(key));
43
+ }
44
+
45
+ export default purgeAppData;
@@ -0,0 +1,26 @@
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
+ // Whether the app locks on coming back: it was in the background for unlockAfter seconds or
18
+ // longer. backgroundedAt is null when it never left.
19
+ function shouldLock({ backgroundedAt, now, unlockAfter }) {
20
+ if (backgroundedAt === null) {
21
+ return false;
22
+ }
23
+ return now - backgroundedAt >= unlockAfter * 1000;
24
+ }
25
+
26
+ export default shouldLock;
@@ -0,0 +1,97 @@
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 Crypto from 'expo-crypto';
18
+ import * as WebBrowser from 'expo-web-browser';
19
+ import { Platform } from 'react-native';
20
+ import { createPkceChallenge } from '@lowdefy/client-core';
21
+
22
+ import getSheetReturnUrl from './getSheetReturnUrl.js';
23
+ import parseQuery from './parseQuery.js';
24
+
25
+ const VERIFIER_BYTES = 64;
26
+
27
+ function sha256(data) {
28
+ return Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, data);
29
+ }
30
+
31
+ /**
32
+ * Signs in through the app's own sign-in page in a system sheet: every method the web app offers
33
+ * (passkeys, SSO, magic links, a second factor).
34
+ *
35
+ * - The sheet opens /auth/mobile with a random state, a PKCE S256 challenge and the platform. On
36
+ * iOS it is ephemeral (no cookies shared with Safari).
37
+ * - Once the person is signed in, the page asks them to confirm. On Continue the server returns a
38
+ * one-time token for a new phone session, bound to the challenge: on iOS to
39
+ * <scheme>://auth?token=&state=, which ASWebAuthenticationSession hands only to this app; on
40
+ * Android to the verified app link <app url>/auth/mobile/callback?token=&state=, which Android
41
+ * opens only in this shell (getSheetReturnUrl).
42
+ * - The shell checks state is its own, then exchanges the token with the code verifier for the
43
+ * session token. A return with another state is refused, so a link this sign-in did not start is
44
+ * never exchanged. The verifier lives in this call only: it is in no URL and never stored.
45
+ *
46
+ * Returns { status }: `signedIn`, `cancelled` (the person closed the sheet or pressed Cancel) or
47
+ * `failed`.
48
+ *
49
+ * @param {Object} params
50
+ * @param {string} params.apiBase The app server's URL.
51
+ * @param {Object} params.authApi createAuthApi.
52
+ * @param {{ id, scheme }} params.shell The shell config.
53
+ */
54
+ async function signInWithSheet({ apiBase, authApi, shell }) {
55
+ const state = Crypto.randomUUID();
56
+ const { codeVerifier, codeChallenge, codeChallengeMethod } = await createPkceChallenge({
57
+ verifierBytes: Crypto.getRandomBytes(VERIFIER_BYTES),
58
+ sha256,
59
+ });
60
+ const platform = Platform.OS === 'android' ? 'android' : 'ios';
61
+ const query = [
62
+ ['shell', shell.id],
63
+ ['state', state],
64
+ ['code_challenge', codeChallenge],
65
+ ['code_challenge_method', codeChallengeMethod],
66
+ ['platform', platform],
67
+ ]
68
+ .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
69
+ .join('&');
70
+ const returnUrl = getSheetReturnUrl({ apiBase, platform, shell });
71
+ const result = await WebBrowser.openAuthSessionAsync(
72
+ `${apiBase}/auth/mobile?${query}`,
73
+ returnUrl,
74
+ {
75
+ preferEphemeralSession: true,
76
+ }
77
+ );
78
+ if (result.type !== 'success') {
79
+ return { status: 'cancelled' };
80
+ }
81
+ if (!result.url.startsWith(returnUrl)) {
82
+ return { status: 'failed' };
83
+ }
84
+ const params = parseQuery(result.url);
85
+ if (params.state !== state) {
86
+ return { status: 'failed' };
87
+ }
88
+ if (params.error === 'cancelled') {
89
+ return { status: 'cancelled' };
90
+ }
91
+ if (!params.token) {
92
+ return { status: 'failed' };
93
+ }
94
+ return authApi.exchangeToken({ token: params.token, codeVerifier });
95
+ }
96
+
97
+ export default signInWithSheet;
@@ -0,0 +1,205 @@
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 { createHash } from 'node:crypto';
18
+ import { jest } from '@jest/globals';
19
+ import { Platform } from 'react-native';
20
+
21
+ import getSheetReturnUrl from './getSheetReturnUrl.js';
22
+ import isSheetReturnLink from './isSheetReturnLink.js';
23
+ import parseQuery from './parseQuery.js';
24
+ import signInWithSheet from './signInWithSheet.js';
25
+
26
+ jest.mock('expo-crypto', () => {
27
+ const nodeCrypto = jest.requireActual('node:crypto');
28
+ return {
29
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
30
+ digest: jest.fn(async (algorithm, data) =>
31
+ nodeCrypto.createHash('sha256').update(data).digest()
32
+ ),
33
+ getRandomBytes: jest.fn((count) => new Uint8Array(nodeCrypto.randomBytes(count))),
34
+ randomUUID: jest.fn(() => 'state-1'),
35
+ };
36
+ });
37
+
38
+ jest.mock('expo-web-browser', () => ({ openAuthSessionAsync: jest.fn() }));
39
+
40
+ const WebBrowser = jest.requireMock('expo-web-browser');
41
+
42
+ const apiBase = 'https://field.example.com';
43
+ const shell = { id: 'field', scheme: 'com.example.field' };
44
+
45
+ function base64Url(buffer) {
46
+ return Buffer.from(buffer).toString('base64url');
47
+ }
48
+
49
+ function createAuthApi() {
50
+ return { exchangeToken: jest.fn(async () => ({ status: 'signedIn' })) };
51
+ }
52
+
53
+ afterEach(() => {
54
+ jest.restoreAllMocks();
55
+ });
56
+
57
+ function onAndroid() {
58
+ jest.replaceProperty(Platform, 'OS', 'android');
59
+ }
60
+
61
+ test('the sheet opens /auth/mobile ephemeral, with the shell, a state, an S256 challenge and the platform', async () => {
62
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
63
+ type: 'success',
64
+ url: 'com.example.field://auth?token=ott-1&state=state-1',
65
+ });
66
+ const authApi = createAuthApi();
67
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'signedIn' });
68
+ const [url, redirectUrl, options] = WebBrowser.openAuthSessionAsync.mock.calls[0];
69
+ expect(url.startsWith(`${apiBase}/auth/mobile?`)).toBe(true);
70
+ const query = parseQuery(url);
71
+ expect(query.shell).toBe('field');
72
+ expect(query.state).toBe('state-1');
73
+ expect(query.code_challenge_method).toBe('S256');
74
+ expect(query.code_challenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
75
+ expect(query.platform).toBe('ios');
76
+ expect(redirectUrl).toBe('com.example.field://auth');
77
+ expect(options).toEqual({ preferEphemeralSession: true });
78
+
79
+ // The verifier goes only to the exchange, and it is the one the challenge was made from.
80
+ const { token, codeVerifier } = authApi.exchangeToken.mock.calls[0][0];
81
+ expect(token).toBe('ott-1');
82
+ expect(codeVerifier).toHaveLength(86);
83
+ expect(base64Url(createHash('sha256').update(codeVerifier).digest())).toBe(query.code_challenge);
84
+ expect(url).not.toContain(codeVerifier);
85
+ });
86
+
87
+ test('each sign-in makes a new verifier, which stays in the call that made it', async () => {
88
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
89
+ type: 'success',
90
+ url: 'com.example.field://auth?token=ott-1&state=state-1',
91
+ });
92
+ const authApi = createAuthApi();
93
+ await signInWithSheet({ apiBase, authApi, shell });
94
+ await signInWithSheet({ apiBase, authApi, shell });
95
+ const [first, second] = authApi.exchangeToken.mock.calls.map(([params]) => params.codeVerifier);
96
+ expect(first).not.toBe(second);
97
+ // No call out of the sign-in but the exchange carries the verifier: not the sheet, not storage.
98
+ const Crypto = jest.requireMock('expo-crypto');
99
+ const otherCalls = JSON.stringify([
100
+ WebBrowser.openAuthSessionAsync.mock.calls,
101
+ Crypto.randomUUID.mock.calls,
102
+ Crypto.getRandomBytes.mock.calls,
103
+ ]);
104
+ expect(otherCalls).not.toContain(first);
105
+ expect(otherCalls).not.toContain(second);
106
+ });
107
+
108
+ test('a redirect with another state is refused and the token is not exchanged', async () => {
109
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
110
+ type: 'success',
111
+ url: 'com.example.field://auth?token=ott-1&state=someone-else',
112
+ });
113
+ const authApi = createAuthApi();
114
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
115
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
116
+ });
117
+
118
+ test('a redirect without a token fails', async () => {
119
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
120
+ type: 'success',
121
+ url: 'com.example.field://auth?state=state-1',
122
+ });
123
+ const authApi = createAuthApi();
124
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
125
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
126
+ });
127
+
128
+ test('on Android the sheet returns by the https app link and exchanges its token', async () => {
129
+ onAndroid();
130
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
131
+ type: 'success',
132
+ url: `${apiBase}/auth/mobile/callback?token=ott-1&state=state-1`,
133
+ });
134
+ const authApi = createAuthApi();
135
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'signedIn' });
136
+ const [url, redirectUrl] = WebBrowser.openAuthSessionAsync.mock.calls[0];
137
+ expect(parseQuery(url).platform).toBe('android');
138
+ expect(redirectUrl).toBe(`${apiBase}/auth/mobile/callback`);
139
+ expect(authApi.exchangeToken.mock.calls[0][0].token).toBe('ott-1');
140
+ });
141
+
142
+ test('on Android a callback with another state is refused and the token is not exchanged', async () => {
143
+ onAndroid();
144
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
145
+ type: 'success',
146
+ url: `${apiBase}/auth/mobile/callback?token=ott-1&state=someone-else`,
147
+ });
148
+ const authApi = createAuthApi();
149
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
150
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
151
+ });
152
+
153
+ test('on Android a return to the custom scheme is not the app link and is refused', async () => {
154
+ onAndroid();
155
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
156
+ type: 'success',
157
+ url: 'com.example.field://auth?token=ott-1&state=state-1',
158
+ });
159
+ const authApi = createAuthApi();
160
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
161
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
162
+ });
163
+
164
+ test('Cancel on the confirmation page cancels the sign-in', async () => {
165
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
166
+ type: 'success',
167
+ url: 'com.example.field://auth?error=cancelled&state=state-1',
168
+ });
169
+ const authApi = createAuthApi();
170
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'cancelled' });
171
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
172
+ });
173
+
174
+ test('getSheetReturnUrl is the scheme on iOS and the app link under the app url on Android', () => {
175
+ expect(getSheetReturnUrl({ apiBase, platform: 'ios', shell })).toBe('com.example.field://auth');
176
+ expect(getSheetReturnUrl({ apiBase: `${apiBase}/app/`, platform: 'android', shell })).toBe(
177
+ `${apiBase}/app/auth/mobile/callback`
178
+ );
179
+ });
180
+
181
+ test('isSheetReturnLink tells the app link return from page links', () => {
182
+ expect(isSheetReturnLink(`${apiBase}/auth/mobile/callback?token=ott-1&state=s`)).toBe(true);
183
+ expect(isSheetReturnLink(`${apiBase}/app/auth/mobile/callback`)).toBe(true);
184
+ expect(isSheetReturnLink('/auth/mobile/callback?token=ott-1')).toBe(true);
185
+ expect(isSheetReturnLink(`${apiBase}/auth/mobile`)).toBe(false);
186
+ expect(isSheetReturnLink(`${apiBase}/jobs?next=/auth/mobile/callback`)).toBe(false);
187
+ expect(isSheetReturnLink('/jobs/42')).toBe(false);
188
+ expect(isSheetReturnLink('com.example.field://jobs')).toBe(false);
189
+ });
190
+
191
+ test('closing the sheet cancels the sign-in', async () => {
192
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({ type: 'cancel' });
193
+ const authApi = createAuthApi();
194
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'cancelled' });
195
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
196
+ });
197
+
198
+ test('parseQuery reads a custom-scheme URL query, decoding each value', () => {
199
+ expect(parseQuery('com.example.field://auth?token=a%2Bb&state=x+y&empty#frag')).toEqual({
200
+ token: 'a+b',
201
+ state: 'x y',
202
+ empty: '',
203
+ });
204
+ expect(parseQuery('com.example.field://auth')).toEqual({});
205
+ });
@@ -0,0 +1,78 @@
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 { StyleSheet } from 'react-native';
18
+
19
+ const styles = StyleSheet.create({
20
+ screen: {
21
+ flexGrow: 1,
22
+ justifyContent: 'center',
23
+ padding: 24,
24
+ gap: 16,
25
+ },
26
+ appName: {
27
+ fontSize: 28,
28
+ fontWeight: '700',
29
+ textAlign: 'center',
30
+ marginBottom: 8,
31
+ },
32
+ label: {
33
+ fontSize: 16,
34
+ },
35
+ input: {
36
+ borderRadius: 10,
37
+ paddingHorizontal: 16,
38
+ paddingVertical: 12,
39
+ fontSize: 17,
40
+ },
41
+ codeInput: {
42
+ letterSpacing: 8,
43
+ textAlign: 'center',
44
+ fontSize: 24,
45
+ },
46
+ message: {
47
+ fontSize: 15,
48
+ },
49
+ error: {
50
+ fontSize: 15,
51
+ },
52
+ button: {
53
+ alignItems: 'center',
54
+ paddingVertical: 14,
55
+ borderRadius: 12,
56
+ },
57
+ buttonDisabled: {
58
+ opacity: 0.5,
59
+ },
60
+ buttonText: {
61
+ color: '#FFFFFF',
62
+ fontSize: 17,
63
+ fontWeight: '600',
64
+ },
65
+ links: {
66
+ flexDirection: 'row',
67
+ justifyContent: 'space-between',
68
+ },
69
+ link: {
70
+ fontSize: 17,
71
+ paddingVertical: 8,
72
+ },
73
+ centered: {
74
+ alignSelf: 'center',
75
+ },
76
+ });
77
+
78
+ export default styles;
@@ -0,0 +1,21 @@
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
+ // Unregisters this phone's push token for the app at sign-out, so no notification for the person
18
+ // reaches it afterwards. Push arrives with its own design; until then there is nothing to remove.
19
+ async function unregisterPushToken() {}
20
+
21
+ export default unregisterPushToken;