@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,60 @@
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 BUILD_HEADER = 'x-lowdefy-build';
18
+
19
+ /**
20
+ * The fetch the core's requests and endpoint calls go through.
21
+ *
22
+ * - A page request carries the build of the root the shell holds (the core stamps the build it was
23
+ * created with; the shell keeps it current without rebuilding the core).
24
+ * - The server refuses a page request from another build with a 409. The shell then syncs: it
25
+ * refetches the root and the changed pages. When the request's own page did not change with the
26
+ * build, its payload is still the shape the new build expects, so the request is retried once,
27
+ * stamped with the new build. When the page changed, the 409 goes to the core, which hands it to
28
+ * onBuildChanged: the screen is rebuilt on the new page and runs its requests again.
29
+ *
30
+ * @param {Object} params
31
+ * @param {Function} params.fetch The Fetch API, sending the shell header (withShellHeader).
32
+ * @param {Object} params.session The app session (createAppSession).
33
+ */
34
+ function createShellFetch({ fetch, session }) {
35
+ return async function shellFetch(url, init = {}) {
36
+ const headers = { ...init.headers };
37
+ const stamped = headers[BUILD_HEADER] !== undefined;
38
+ if (stamped) {
39
+ headers[BUILD_HEADER] = session.getState().buildId ?? headers[BUILD_HEADER];
40
+ }
41
+ const res = await fetch(url, { ...init, headers });
42
+ if (res.status !== 409 || !stamped) {
43
+ return res;
44
+ }
45
+ const before = session.getRequestPageEntry(url);
46
+ try {
47
+ await session.sync();
48
+ } catch (error) {
49
+ return res;
50
+ }
51
+ const after = session.getRequestPageEntry(url);
52
+ if (before === null || after === null || before.hash !== after.hash) {
53
+ return res;
54
+ }
55
+ const buildId = session.getState().buildId ?? headers[BUILD_HEADER];
56
+ return fetch(url, { ...init, headers: { ...headers, [BUILD_HEADER]: buildId } });
57
+ };
58
+ }
59
+
60
+ export default createShellFetch;
@@ -0,0 +1,150 @@
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 409 syncs the root and changed pages, then retries the request once on the new build', async () => {
79
+ const { server, session, shellFetch } = await setup();
80
+ deploy({ server, todayHash: 'h-today-2' });
81
+ server.state.pages.today = { pageConfig: { pageId: 'today', v: 2 }, etag: '"p-today-2"' };
82
+ server.fetch.mockClear();
83
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, requestInit);
84
+ expect(res.status).toBe(200);
85
+ expect(await res.json()).toEqual({ id: 'get_jobs', success: true, response: [] });
86
+ const urls = server.fetch.mock.calls.map(([url]) => new URL(url).pathname);
87
+ expect(urls).toEqual([
88
+ '/api/request/jobs/get_jobs',
89
+ '/api/mobile/root',
90
+ '/api/user',
91
+ '/api/page/today',
92
+ '/api/request/jobs/get_jobs',
93
+ ]);
94
+ expect(header(server.fetch.mock.calls.at(-1)[1], 'x-lowdefy-build')).toBe('build-2');
95
+ expect(session.getState().buildId).toBe('build-2');
96
+ expect(session.getState().pages.today.pageConfig.v).toBe(2);
97
+ });
98
+
99
+ test('a 409 for a page that changed with the build goes to the core, not retried', async () => {
100
+ const { server, session, shellFetch } = await setup();
101
+ deploy({ server, todayHash: 'h-today-2' });
102
+ server.fetch.mockClear();
103
+ const res = await shellFetch(`${apiBase}/api/request/today/get_jobs`, requestInit);
104
+ expect(res.status).toBe(409);
105
+ expect((await res.json()).buildId).toBe('build-2');
106
+ expect(server.fetch.mock.calls.filter(([url]) => url.includes('/api/request/'))).toHaveLength(1);
107
+ expect(session.getState().buildId).toBe('build-2');
108
+ });
109
+
110
+ test('a second 409 after the retry goes to the core', async () => {
111
+ const { server, shellFetch } = await setup();
112
+ deploy({ server, todayHash: 'h-today-1' });
113
+ const serve = server.fetch.getMockImplementation();
114
+ server.fetch.mockImplementation(async (url, init) => {
115
+ if (url.includes('/api/request/')) {
116
+ return {
117
+ ok: false,
118
+ status: 409,
119
+ headers: { get: () => null },
120
+ json: async () => ({ buildId: 'build-3' }),
121
+ };
122
+ }
123
+ return serve(url, init);
124
+ });
125
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, requestInit);
126
+ expect(res.status).toBe(409);
127
+ expect(server.fetch.mock.calls.filter(([url]) => url.includes('/api/request/'))).toHaveLength(2);
128
+ });
129
+
130
+ test('a 409 the shell cannot sync for goes to the core', async () => {
131
+ const { server, shellFetch } = await setup();
132
+ deploy({ server, todayHash: 'h-today-1' });
133
+ const serve = server.fetch.getMockImplementation();
134
+ server.fetch.mockImplementation(async (url, init) => {
135
+ if (url.includes('/api/mobile/root')) throw new TypeError('Network request failed');
136
+ return serve(url, init);
137
+ });
138
+ const res = await shellFetch(`${apiBase}/api/request/jobs/get_jobs`, requestInit);
139
+ expect(res.status).toBe(409);
140
+ });
141
+
142
+ test('a call without a build stamp (an endpoint call) is never retried', async () => {
143
+ const { server, shellFetch } = await setup();
144
+ deploy({ server, todayHash: 'h-today-1' });
145
+ server.fetch.mockClear();
146
+ const res = await shellFetch(`${apiBase}/api/endpoints/save`, { method: 'POST', headers: {} });
147
+ expect(res.status).toBe(404);
148
+ expect(server.fetch).toHaveBeenCalledTimes(1);
149
+ expect(header(server.fetch.mock.calls[0][1], 'x-lowdefy-build')).toBeNull();
150
+ });
@@ -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,73 @@
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({ fetch, router, session, streamFetch, tokenStore, WebSocket }) {
42
+ const storage = createDeviceStore();
43
+ return {
44
+ authTransport: 'bearer',
45
+ fetch,
46
+ globals: createShellGlobals({
47
+ assign: (url) => Linking.openURL(url),
48
+ clipboard,
49
+ fetch,
50
+ getInstanceId: session.getCurrentInstanceId,
51
+ getPath: session.getCurrentPath,
52
+ network,
53
+ origin: getOrigin(session.apiBase),
54
+ storage,
55
+ }),
56
+ onBuildChanged: () => {
57
+ session.handleBuildChanged();
58
+ },
59
+ onCallerChanged: () => {
60
+ session.handleCallerChanged();
61
+ },
62
+ router,
63
+ sessionStorage: createMemoryStore(),
64
+ storage,
65
+ // The Fetch API whose response body streams, for blocks (client-native's createStreamFetch).
66
+ streamFetch,
67
+ tokenStore,
68
+ WebSocket,
69
+ websocketUrl: (apiPath) => apiPath.replace(/^http/, 'ws'),
70
+ };
71
+ }
72
+
73
+ 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,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 origin of the app server's URL: its scheme, host and port.
18
+ function getOrigin(url) {
19
+ const match = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]+/i.exec(url);
20
+ if (match === null) {
21
+ throw new Error(`Shell app url is not an absolute URL. Received ${JSON.stringify(url)}.`);
22
+ }
23
+ return match[0];
24
+ }
25
+
26
+ export default getOrigin;
@@ -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,25 @@
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
+ // Every call a shell makes carries x-lowdefy-shell, its version: the server serves mobile pages
18
+ // only to shells and web pages only to browsers.
19
+ function withShellHeader({ fetch, shellVersion }) {
20
+ return function shellHeaderFetch(url, init = {}) {
21
+ return fetch(url, { ...init, headers: { ...init.headers, 'x-lowdefy-shell': shellVersion } });
22
+ };
23
+ }
24
+
25
+ export default withShellHeader;
@@ -0,0 +1,28 @@
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 withShellHeader from './withShellHeader.js';
20
+
21
+ test('every call carries the shell version as x-lowdefy-shell', async () => {
22
+ const fetch = jest.fn(async () => ({ status: 200 }));
23
+ const shellFetch = withShellHeader({ fetch, shellVersion: '7.4.0' });
24
+ await shellFetch('https://app.example.com/api/page/home', { headers: { Accept: 'x' } });
25
+ await shellFetch('https://app.example.com/api/user');
26
+ expect(fetch.mock.calls[0][1].headers).toEqual({ Accept: 'x', 'x-lowdefy-shell': '7.4.0' });
27
+ expect(fetch.mock.calls[1][1].headers).toEqual({ 'x-lowdefy-shell': '7.4.0' });
28
+ });
@@ -0,0 +1,43 @@
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 { type } from '@lowdefy/helpers';
18
+
19
+ /**
20
+ * The one place the shell turns `_js` source into functions. A shell is shared by every app, so
21
+ * it cannot carry an app's compiled clientJsMap: each mobile page, and the mobile root for the
22
+ * app events, carries its `_js` as `_jsEntries`, the module text the build generates
23
+ * (`export default { [hash]: fn }`). It is evaluated with `new Function`, as the dev server
24
+ * evaluates a page it builds on request (server-dev usePageConfig), and keeps the `volatile` flag
25
+ * the module sets for dependency tracking.
26
+ *
27
+ * Hermes must run `new Function` for this to work. Should it not, `_js` on mobile pages becomes a
28
+ * build error, and this function the one place that refuses it.
29
+ *
30
+ * @param {string|null|undefined} jsEntries The module text; none when the page has no `_js`.
31
+ * @returns {Object} `{ [hash]: fn }`, the jsMap the engine's `_js` operator reads.
32
+ */
33
+ function evaluateJsEntries(jsEntries) {
34
+ if (type.isNone(jsEntries)) {
35
+ return {};
36
+ }
37
+ const define = new Function('exports', jsEntries.replace('export default', 'exports.default ='));
38
+ const module = {};
39
+ define(module);
40
+ return module.default;
41
+ }
42
+
43
+ export default evaluateJsEntries;
@@ -0,0 +1,48 @@
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 evaluateJsEntries from './evaluateJsEntries.js';
18
+ import helloJsEntries from '../../fixtures/hello.jsEntries.json';
19
+
20
+ const accessors = {
21
+ lowdefyGlobal: (key) => ({ greeting: 'Hello from Lowdefy' })[key],
22
+ };
23
+
24
+ test('evaluateJsEntries turns the hello page module text into its _js functions', () => {
25
+ const jsMap = evaluateJsEntries(helloJsEntries.today);
26
+ expect(Object.keys(jsMap)).toEqual([
27
+ 'HorPchxU3cTNYhB6dbx9DFcepx4=',
28
+ 'b+IYcPY3kW9J7YdZ4RyICtelfz8=',
29
+ ]);
30
+ expect(jsMap['HorPchxU3cTNYhB6dbx9DFcepx4='](accessors)).toBe('Hello from Lowdefy');
31
+ expect(jsMap['b+IYcPY3kW9J7YdZ4RyICtelfz8='](accessors)).toBe(String(new Date().getFullYear()));
32
+ });
33
+
34
+ test('evaluateJsEntries keeps the volatile flag dependency tracking reads', () => {
35
+ const jsMap = evaluateJsEntries(helloJsEntries.today);
36
+ expect(jsMap['b+IYcPY3kW9J7YdZ4RyICtelfz8='].volatile).toBe(true);
37
+ expect(jsMap['HorPchxU3cTNYhB6dbx9DFcepx4='].volatile).toBeUndefined();
38
+ });
39
+
40
+ test('evaluateJsEntries evaluates the app events module text of the root', () => {
41
+ const jsMap = evaluateJsEntries(helloJsEntries.app);
42
+ expect(jsMap['yHZGzdxAuSQ8P5USi0AFFY81iNg='](accessors)).toBe('Hello from Lowdefy');
43
+ });
44
+
45
+ test('evaluateJsEntries gives an empty map for a page without _js', () => {
46
+ expect(evaluateJsEntries(undefined)).toEqual({});
47
+ expect(evaluateJsEntries(null)).toEqual({});
48
+ });