@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,68 @@
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 createDevShellConfig from '../connection/createDevShellConfig.js';
20
+ import createDeviceInjections from './createDeviceInjections.js';
21
+ import createFakeNetwork from '../../test/createFakeNetwork.js';
22
+ import createNetworkStatus from '../../host/createNetworkStatus.js';
23
+ import createOfflineFetch from './createOfflineFetch.js';
24
+
25
+ const shell = { environment: 'dev', version: '7.4.0' };
26
+
27
+ function setup({ pair = null } = {}) {
28
+ const injections = createDeviceInjections({
29
+ networkStatus: createNetworkStatus({ network: createFakeNetwork() }),
30
+ });
31
+ const fetch = jest.fn(async () => ({ ok: true }));
32
+ const devShell = createDevShellConfig({
33
+ fetch: createOfflineFetch({ fetch, isOffline: injections.isOffline }),
34
+ server: { url: 'http://192.168.1.20:3000', host: '192.168.1.20:3000', pair },
35
+ shell,
36
+ WebSocket: class {},
37
+ });
38
+ return { devShell, fetch, injections };
39
+ }
40
+
41
+ test("offline, the app's every fetch fails as React Native's fetch fails with no network", async () => {
42
+ const { devShell, fetch, injections } = setup({ pair: 'token' });
43
+ await devShell.transport.fetch('http://192.168.1.20:3000/api/page/home');
44
+ expect(fetch).toHaveBeenCalledTimes(1);
45
+
46
+ injections.inject({ kind: 'network', value: 'offline' });
47
+ const failed = devShell.transport.fetch('http://192.168.1.20:3000/api/request/home/load');
48
+ await expect(failed).rejects.toThrow(TypeError);
49
+ await expect(failed).rejects.toThrow('Network request failed');
50
+ expect(fetch).toHaveBeenCalledTimes(1);
51
+
52
+ injections.inject({ kind: 'network', value: 'online' });
53
+ await devShell.transport.fetch('http://192.168.1.20:3000/api/page/home');
54
+ expect(fetch).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ test('offline, the dev-tab client still reaches the dev server, so an agent can go back online', async () => {
58
+ const { devShell, injections } = setup({ pair: 'token' });
59
+ injections.inject({ kind: 'network', value: 'offline' });
60
+ const tabFetch = jest.fn(async () => ({ ok: true }));
61
+ await devShell.transport.wrapFetch(tabFetch)('http://192.168.1.20:3000/api/dev-inspect', {
62
+ method: 'POST',
63
+ });
64
+ expect(tabFetch).toHaveBeenCalledWith('http://192.168.1.20:3000/api/dev-inspect', {
65
+ method: 'POST',
66
+ headers: { 'x-lowdefy-pair': 'token' },
67
+ });
68
+ });
@@ -0,0 +1,33 @@
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 { Text, View } from 'react-native';
19
+
20
+ import devStyles from '../devStyles.js';
21
+
22
+ // The shake menu's block gallery: every native block the shell carries, shown with its examples.
23
+ // The native block set fills it in; until then it says so.
24
+ function BlockGallery() {
25
+ return (
26
+ <View style={devStyles.screen} testID="dev-block-gallery">
27
+ <Text style={devStyles.heading}>Block gallery</Text>
28
+ <Text style={devStyles.hint}>The gallery of native blocks is not in this build yet.</Text>
29
+ </View>
30
+ );
31
+ }
32
+
33
+ export default BlockGallery;
@@ -0,0 +1,121 @@
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, useSyncExternalStore } from 'react';
18
+ import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
19
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
20
+
21
+ import BlockGallery from './BlockGallery.js';
22
+ import devStyles from '../devStyles.js';
23
+
24
+ function MenuItem({ hint, label, onPress, testID }) {
25
+ return (
26
+ <Pressable accessibilityRole="button" onPress={onPress} style={devStyles.row} testID={testID}>
27
+ <Text style={devStyles.rowText}>{label}</Text>
28
+ {hint ? <Text style={devStyles.hint}>{hint}</Text> : null}
29
+ </Pressable>
30
+ );
31
+ }
32
+
33
+ function LogList({ logs }) {
34
+ const entries = useSyncExternalStore(logs.subscribe, logs.entries);
35
+ if (entries.length === 0) {
36
+ return <Text style={devStyles.hint}>Nothing logged yet.</Text>;
37
+ }
38
+ return entries.map((entry, index) => (
39
+ <Text key={index} style={devStyles.mono}>
40
+ {`${entry.at.toISOString().slice(11, 19)} ${entry.level} ${entry.message}`}
41
+ </Text>
42
+ ));
43
+ }
44
+
45
+ /**
46
+ * The shake menu: reload, switch server, clear data, the shell's logs, the block gallery and the
47
+ * shell's version.
48
+ *
49
+ * @param {Object} props
50
+ * @param {boolean} props.visible
51
+ * @param {() => void} props.onClose
52
+ * @param {() => void} props.onReload
53
+ * @param {() => void} props.onSwitchServer
54
+ * @param {() => void} props.onClearData
55
+ * @param {Object} props.logs The log buffer (createLogBuffer).
56
+ * @param {string|null} props.server The host of the server Lowdefy Dev is connected to.
57
+ * @param {Object} props.shell The shell config: name and version.
58
+ */
59
+ function DevMenu({ logs, onClearData, onClose, onReload, onSwitchServer, server, shell, visible }) {
60
+ const insets = useSafeAreaInsets();
61
+ const [panel, setPanel] = useState('menu');
62
+ function close() {
63
+ setPanel('menu');
64
+ onClose();
65
+ }
66
+ function act(action) {
67
+ close();
68
+ action();
69
+ }
70
+ return (
71
+ <Modal animationType="slide" onRequestClose={close} transparent visible={visible}>
72
+ <View style={devStyles.backdrop}>
73
+ <ScrollView
74
+ contentContainerStyle={[devStyles.sheet, { paddingBottom: insets.bottom + 24 }]}
75
+ style={{ maxHeight: '85%', flexGrow: 0 }}
76
+ testID="dev-menu"
77
+ >
78
+ {panel === 'menu' ? (
79
+ <>
80
+ <Text style={devStyles.sheetTitle}>{shell.name}</Text>
81
+ <Text style={devStyles.hint} testID="dev-menu-version">
82
+ {`Shell ${shell.version}${server ? ` · ${server}` : ''}`}
83
+ </Text>
84
+ <MenuItem label="Reload" onPress={() => act(onReload)} testID="dev-menu-reload" />
85
+ <MenuItem
86
+ hint={server ?? undefined}
87
+ label="Switch server"
88
+ onPress={() => act(onSwitchServer)}
89
+ testID="dev-menu-switch"
90
+ />
91
+ <MenuItem
92
+ hint="Signs out and removes this server's cached pages and storage"
93
+ label="Clear data"
94
+ onPress={() => act(onClearData)}
95
+ testID="dev-menu-clear"
96
+ />
97
+ <MenuItem label="Show logs" onPress={() => setPanel('logs')} testID="dev-menu-logs" />
98
+ <MenuItem
99
+ label="Block gallery"
100
+ onPress={() => setPanel('gallery')}
101
+ testID="dev-menu-gallery"
102
+ />
103
+ </>
104
+ ) : null}
105
+ {panel === 'logs' ? <LogList logs={logs} /> : null}
106
+ {panel === 'gallery' ? <BlockGallery /> : null}
107
+ <Pressable
108
+ accessibilityRole="button"
109
+ onPress={panel === 'menu' ? close : () => setPanel('menu')}
110
+ style={devStyles.secondaryButton}
111
+ testID="dev-menu-close"
112
+ >
113
+ <Text style={devStyles.secondaryButtonText}>{panel === 'menu' ? 'Close' : 'Back'}</Text>
114
+ </Pressable>
115
+ </ScrollView>
116
+ </View>
117
+ </Modal>
118
+ );
119
+ }
120
+
121
+ export default DevMenu;
@@ -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
+ const LEVELS = ['log', 'info', 'warn', 'error'];
18
+
19
+ function formatPart(part) {
20
+ if (part instanceof Error) {
21
+ return part.message;
22
+ }
23
+ if (typeof part === 'string') {
24
+ return part;
25
+ }
26
+ try {
27
+ return JSON.stringify(part);
28
+ } catch (error) {
29
+ return String(part);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * The shell's recent console output, for the shake menu's "Show logs": the last `limit` lines,
35
+ * `{ at, level, message }`, oldest first. install() copies every console call into it (the console
36
+ * still prints); it returns the uninstall function.
37
+ *
38
+ * @param {Object} params
39
+ * @param {Object} params.console The console to follow.
40
+ * @param {number} [params.limit]
41
+ */
42
+ function createLogBuffer({ console, limit = 200 }) {
43
+ // Replaced, never changed, on each line: a snapshot for useSyncExternalStore.
44
+ let entries = [];
45
+ const listeners = new Set();
46
+
47
+ function add({ level, parts }) {
48
+ const entry = { at: new Date(), level, message: parts.map(formatPart).join(' ') };
49
+ entries = [...entries, entry].slice(-limit);
50
+ // A component may log while React renders another: the log view hears of it afterwards.
51
+ setTimeout(() => listeners.forEach((listener) => listener()), 0);
52
+ }
53
+
54
+ function install() {
55
+ const originals = {};
56
+ LEVELS.forEach((level) => {
57
+ originals[level] = console[level];
58
+ console[level] = (...parts) => {
59
+ add({ level, parts });
60
+ originals[level].apply(console, parts);
61
+ };
62
+ });
63
+ return () => {
64
+ LEVELS.forEach((level) => {
65
+ console[level] = originals[level];
66
+ });
67
+ };
68
+ }
69
+
70
+ function subscribe(listener) {
71
+ listeners.add(listener);
72
+ return () => listeners.delete(listener);
73
+ }
74
+
75
+ return {
76
+ add: (level, ...parts) => add({ level, parts }),
77
+ entries: () => entries,
78
+ install,
79
+ subscribe,
80
+ };
81
+ }
82
+
83
+ export default createLogBuffer;
@@ -0,0 +1,52 @@
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
+ const UPDATE_MS = 100;
18
+ // In g: a phone at rest reads about 1.
19
+ const SHAKE_FORCE = 1.8;
20
+ const JOLTS = 3;
21
+ const WINDOW_MS = 800;
22
+ const QUIET_MS = 1500;
23
+
24
+ /**
25
+ * Calls onShake when the phone is shaken: 3 jolts over 1.8 g within 0.8 s, then not again for 1.5 s.
26
+ * Returns the function that stops listening.
27
+ *
28
+ * @param {Object} params
29
+ * @param {Object} params.accelerometer expo-sensors' Accelerometer.
30
+ * @param {() => void} params.onShake
31
+ * @param {() => number} [params.now]
32
+ */
33
+ function watchShake({ accelerometer, now = Date.now, onShake }) {
34
+ let jolts = [];
35
+ let quietUntil = 0;
36
+ accelerometer.setUpdateInterval(UPDATE_MS);
37
+ const subscription = accelerometer.addListener(({ x, y, z }) => {
38
+ const time = now();
39
+ if (time < quietUntil || Math.sqrt(x * x + y * y + z * z) < SHAKE_FORCE) {
40
+ return;
41
+ }
42
+ jolts = [...jolts.filter((at) => time - at <= WINDOW_MS), time];
43
+ if (jolts.length >= JOLTS) {
44
+ jolts = [];
45
+ quietUntil = time + QUIET_MS;
46
+ onShake();
47
+ }
48
+ });
49
+ return () => subscription.remove();
50
+ }
51
+
52
+ export default watchShake;
@@ -0,0 +1,61 @@
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 watchShake from './watchShake.js';
20
+
21
+ function setup() {
22
+ let listener = null;
23
+ let time = 0;
24
+ const onShake = jest.fn();
25
+ const stop = watchShake({
26
+ accelerometer: {
27
+ setUpdateInterval: jest.fn(),
28
+ addListener: (fn) => {
29
+ listener = fn;
30
+ return { remove: jest.fn() };
31
+ },
32
+ },
33
+ now: () => time,
34
+ onShake,
35
+ });
36
+ function read(g, at) {
37
+ time = at;
38
+ listener({ x: g, y: 0, z: 0 });
39
+ }
40
+ return { onShake, read, stop };
41
+ }
42
+
43
+ test('three jolts within 0.8 s open the menu once, then it rests for 1.5 s', () => {
44
+ const { onShake, read } = setup();
45
+ read(2.5, 0);
46
+ read(2.5, 200);
47
+ expect(onShake).not.toHaveBeenCalled();
48
+ read(2.5, 400);
49
+ expect(onShake).toHaveBeenCalledTimes(1);
50
+ [500, 600, 700].forEach((at) => read(2.5, at));
51
+ expect(onShake).toHaveBeenCalledTimes(1);
52
+ [2000, 2100, 2200].forEach((at) => read(2.5, at));
53
+ expect(onShake).toHaveBeenCalledTimes(2);
54
+ });
55
+
56
+ test('a phone at rest, or jolts spread out, do not open the menu', () => {
57
+ const { onShake, read } = setup();
58
+ [0, 100, 200, 300].forEach((at) => read(1, at));
59
+ [1000, 2000, 3000].forEach((at) => read(2.5, at));
60
+ expect(onShake).not.toHaveBeenCalled();
61
+ });
@@ -0,0 +1,204 @@
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 {
18
+ createPairingBuffer,
19
+ createPasswordRedactor,
20
+ createRecordBatcher,
21
+ createRecordingSession,
22
+ sendRecords,
23
+ } from '@lowdefy/client-core';
24
+ import { describeBlock } from '@lowdefy/client-native';
25
+ import { getTrace } from '@lowdefy/engine';
26
+ import { type } from '@lowdefy/helpers';
27
+
28
+ import getShownPage from '../tab/getShownPage.js';
29
+
30
+ const PAGE_CHECK_MS = 500;
31
+
32
+ // A TextInput of type password draws a secure field; its value is never recorded.
33
+ function isPasswordBlock(block) {
34
+ return block?.eval?.properties?.type === 'password';
35
+ }
36
+
37
+ function findBlock({ blockId, lowdefy, page }) {
38
+ return (
39
+ lowdefy.contexts?.[page?.instanceKey]?._internal.RootSlots?.map?.[blockId] ??
40
+ lowdefy.appContext?._internal.RootSlots?.map?.[blockId] ??
41
+ null
42
+ );
43
+ }
44
+
45
+ function currentUrl(lowdefy) {
46
+ const { location } = lowdefy._internal.globals.window;
47
+ return `${location.pathname}${location.search}`;
48
+ }
49
+
50
+ // The device store as the sessionStorage the recording session keeps its id in, so a session lasts
51
+ // across reloads and a restart of the app within 10 minutes.
52
+ function createSessionStorage(kv) {
53
+ return {
54
+ getItem: (key) => kv.get(key) ?? null,
55
+ setItem: (key, value) => kv.set(key, value),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Lowdefy Dev's recorder: what a developer does on the phone, written as the web recorder writes
61
+ * it (server-dev client/recorder/createRecorder.js), so `lowdefy journeys session` and the
62
+ * journeys-from-dev skill read phone sessions as they read browser ones.
63
+ *
64
+ * - The engine trace's interactions (client-native reports presses and changes by block id) pair
65
+ * with its completed events through the shared pairing buffer (@lowdefy/client-core).
66
+ * - A pageview is recorded when the page on screen changes, checked every 500 ms and before each
67
+ * interaction and event.
68
+ * - Targets are block ids (client-native describeBlock). Password inputs are redacted.
69
+ * - Each record is labelled with the device (`ios` or `android`) and the shell's version, and
70
+ * batches are posted to the dev server's /api/dev-recording.
71
+ * - A reload of the app makes a new lowdefy object: the recorder follows it.
72
+ *
73
+ * @param {Object} params
74
+ * @param {string} params.apiBase The dev server's URL.
75
+ * @param {string} params.device 'ios' or 'android'.
76
+ * @param {Function} params.fetch The Fetch API, with the pairing and shell headers.
77
+ * @param {() => string|null} params.getBuild The build of the root the shell holds.
78
+ * @param {() => Object|null} params.getLowdefy The open app's lowdefy object.
79
+ * @param {Object} params.kv The device store.
80
+ * @param {string} params.shellVersion
81
+ * @param {() => number} [params.now]
82
+ */
83
+ function createNativeRecorder({
84
+ apiBase,
85
+ device,
86
+ fetch,
87
+ getBuild,
88
+ getLowdefy,
89
+ kv,
90
+ now = Date.now,
91
+ shellVersion,
92
+ }) {
93
+ const session = createRecordingSession({ storage: createSessionStorage(kv), now });
94
+ const batcher = createRecordBatcher({
95
+ send: (records) => sendRecords({ basePath: apiBase, records, fetch }),
96
+ });
97
+ let lowdefy = null;
98
+ let detach = () => undefined;
99
+ let shown = null;
100
+ let timer = null;
101
+
102
+ const buffer = createPairingBuffer({
103
+ onRecord: (record) => batcher.add({ ...record, device, shell_version: shellVersion }),
104
+ now,
105
+ getSession: () => session.getId(),
106
+ getRoles: () => lowdefy?.user?.roles ?? [],
107
+ getBuild,
108
+ redactor: createPasswordRedactor({ isPassword: isPasswordBlock }),
109
+ });
110
+
111
+ // Records a pageview when the page instance on screen changed. `t` places it before the
112
+ // interaction or event that found the change, which happened on the new page.
113
+ function checkPage({ t } = {}) {
114
+ if (lowdefy === null) return;
115
+ const page = getShownPage(lowdefy);
116
+ if (page === null || page.instanceKey === shown?.instanceKey) return;
117
+ shown = page;
118
+ buffer.addInteraction({
119
+ t: t ?? now(),
120
+ kind: 'pageview',
121
+ pageId: page.pageId,
122
+ pathParams: page.pathParams,
123
+ url: currentUrl(lowdefy),
124
+ });
125
+ }
126
+
127
+ function onInteraction(interaction) {
128
+ try {
129
+ checkPage({ t: interaction.t - 1 });
130
+ const target = describeBlock({ blockId: interaction.blockId, lowdefy });
131
+ buffer.addInteraction({
132
+ t: interaction.t,
133
+ kind: interaction.kind,
134
+ element: findBlock({ blockId: interaction.blockId, lowdefy, page: shown }),
135
+ target,
136
+ pageId: target.page_id ?? shown?.pageId,
137
+ value: interaction.value,
138
+ key: interaction.key,
139
+ });
140
+ } catch (error) {
141
+ // Recording is best effort: it never gets in the app's way.
142
+ }
143
+ }
144
+
145
+ function onTrace(payload) {
146
+ try {
147
+ checkPage();
148
+ buffer.addEvent(payload, { urlAfter: currentUrl(lowdefy) });
149
+ } catch (error) {
150
+ // Recording is best effort.
151
+ }
152
+ }
153
+
154
+ function flush() {
155
+ try {
156
+ buffer.flushAll();
157
+ return batcher.flush();
158
+ } catch (error) {
159
+ return Promise.resolve();
160
+ }
161
+ }
162
+
163
+ // Follows the open app's lowdefy object, which a reload replaces.
164
+ function follow() {
165
+ const next = getLowdefy();
166
+ if (next === lowdefy) {
167
+ checkPage();
168
+ return;
169
+ }
170
+ detach();
171
+ flush();
172
+ lowdefy = next;
173
+ shown = null;
174
+ if (type.isNone(lowdefy)) {
175
+ lowdefy = null;
176
+ detach = () => undefined;
177
+ return;
178
+ }
179
+ const trace = getTrace(lowdefy);
180
+ const unsubscribeEvents = trace.subscribe(onTrace, { state: true });
181
+ const unsubscribeInteractions = trace.subscribeInteractions(onInteraction);
182
+ detach = () => {
183
+ unsubscribeEvents();
184
+ unsubscribeInteractions();
185
+ };
186
+ checkPage();
187
+ }
188
+
189
+ function start() {
190
+ follow();
191
+ timer = setInterval(follow, PAGE_CHECK_MS);
192
+ }
193
+
194
+ function stop() {
195
+ clearInterval(timer);
196
+ detach();
197
+ detach = () => undefined;
198
+ return flush();
199
+ }
200
+
201
+ return { flush, start, stop };
202
+ }
203
+
204
+ export default createNativeRecorder;