@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,105 @@
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 crypto = require('node:crypto');
18
+
19
+ function isPositiveInteger(value) {
20
+ return Number.isInteger(value) && value > 0;
21
+ }
22
+
23
+ function isObject(value) {
24
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
25
+ }
26
+
27
+ // JSON with every object's keys sorted, at every depth, so options that differ only in key order
28
+ // give the same text.
29
+ function canonicalJson(value) {
30
+ if (Array.isArray(value)) {
31
+ return `[${value.map(canonicalJson).join(',')}]`;
32
+ }
33
+ if (isObject(value)) {
34
+ const entries = Object.keys(value)
35
+ .sort()
36
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
37
+ return `{${entries.join(',')}}`;
38
+ }
39
+ return JSON.stringify(value);
40
+ }
41
+
42
+ // A short hash of the shell plugins a shell carries, the same whatever order they are listed in.
43
+ // Each plugin is keyed on name@version and its options: config plugins bake the options into the
44
+ // native project, so an option change is a native change. No options and {} hash the same.
45
+ function hashPlugins(plugins) {
46
+ const keys = plugins.map((plugin) => {
47
+ if (typeof plugin?.name !== 'string' || typeof plugin?.version !== 'string') {
48
+ throw new Error(
49
+ `Each shell plugin should be { name, version } with string values. Received ${JSON.stringify(
50
+ plugin
51
+ )}.`
52
+ );
53
+ }
54
+ if (plugin.options !== undefined && !isObject(plugin.options)) {
55
+ throw new Error(
56
+ `Shell plugin "${plugin.name}" options should be an object. Received ${JSON.stringify(
57
+ plugin.options
58
+ )}.`
59
+ );
60
+ }
61
+ return `${plugin.name}@${plugin.version} ${canonicalJson(plugin.options ?? {})}`;
62
+ });
63
+ return crypto.createHash('sha256').update(keys.sort().join('\n')).digest('hex').slice(0, 8);
64
+ }
65
+
66
+ /**
67
+ * The runtime version of a shell build: `<nativeVersion>-<profile>`, and `-<plugins hash>` when the
68
+ * shell carries shell plugins. An over-the-air update reaches only binaries with the same runtime
69
+ * version, so it changes only with the shell's native code (`nativeVersion` in package.json, bumped
70
+ * on purpose and kept honest by scripts/checkNativeVersion.mjs), its capability profile and its
71
+ * plugins. Shell identity (name, ids, scheme, icon, colour, URLs) is not part of it, so every shell
72
+ * on one nativeVersion and profile shares a runtime version.
73
+ *
74
+ * Pure, with no Expo import, so `lowdefy mobile` computes the same value app.config.ts sets from the copied
75
+ * shell's runtimeVersion.js and package.json `nativeVersion`.
76
+ *
77
+ * @param {Object} params
78
+ * @param {number} params.nativeVersion The shell package's nativeVersion.
79
+ * @param {string} params.profile The shell's capability profile.
80
+ * @param {{ name: string, version: string, options?: Object }[]} [params.plugins] The shell plugins
81
+ * it carries (shell.json `plugins`).
82
+ * @returns {string}
83
+ */
84
+ function getRuntimeVersion({ nativeVersion, profile, plugins = [] }) {
85
+ if (!isPositiveInteger(nativeVersion)) {
86
+ throw new Error(
87
+ `nativeVersion should be a positive integer. Received ${JSON.stringify(nativeVersion)}.`
88
+ );
89
+ }
90
+ if (typeof profile !== 'string' || profile.length === 0) {
91
+ throw new Error(`profile should be a string. Received ${JSON.stringify(profile)}.`);
92
+ }
93
+ if (!Array.isArray(plugins)) {
94
+ throw new Error(`plugins should be an array. Received ${JSON.stringify(plugins)}.`);
95
+ }
96
+ if (plugins.length === 0) {
97
+ return `${nativeVersion}-${profile}`;
98
+ }
99
+ return `${nativeVersion}-${profile}-${hashPlugins(plugins)}`;
100
+ }
101
+
102
+ // `require('./runtimeVersion.js')` is the function, and `.getRuntimeVersion` names it too, so
103
+ // `lowdefy mobile` can load it from the copied shell either way.
104
+ module.exports = getRuntimeVersion;
105
+ module.exports.getRuntimeVersion = getRuntimeVersion;
@@ -0,0 +1,358 @@
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
+ The release check: a shell carries only its capability profile's native modules (profiles.js),
19
+ and a customer shell none of Lowdefy Dev's developer features, in its JavaScript or in its native
20
+ projects. A shell's release build runs it before a store build.
21
+
22
+ For each release profile (basic, field, field-nfc, field-tracking; --profile names one) and each
23
+ platform, with LOWDEFY_SHELL_VARIANT=release and the shell config LOWDEFY_SHELL_CONFIG names
24
+ (Lowdefy Dev's shell.dev.json without it) set to that profile:
25
+
26
+ 1. Bundle: exports the JavaScript bundle and fails when any file of it holds the dev-tab client's
27
+ marker (src/dev/tab/devClientMarker.js) or the native module name of a module the profile
28
+ leaves out (getExclusions in shellVariant.js).
29
+ 2. Prebuild: runs `expo prebuild` in a copy of the shell and fails when
30
+ - Expo autolinking, run with the exclusions the generated Podfile and settings.gradle pass it,
31
+ still links a module the profile leaves out; or
32
+ - the native project asks for a permission only a left-out module needs
33
+ (NSCameraUsageDescription and android.permission.CAMERA without the camera,
34
+ NSMotionUsageDescription in any release shell).
35
+
36
+ With --control it first runs both on the dev variant (profile `dev`) and fails when the marker,
37
+ the module names or the modules any release profile leaves out are missing there, which shows the
38
+ check can see them.
39
+
40
+ node scripts/checkReleaseBundle.mjs [--platform ios|android] [--profile <name>] [--control]
41
+ */
42
+
43
+ import { execFileSync } from 'node:child_process';
44
+ import fs from 'node:fs';
45
+ import { createRequire } from 'node:module';
46
+ import os from 'node:os';
47
+ import path from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+
50
+ const shellRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
51
+ const require = createRequire(import.meta.url);
52
+ const { RELEASE_PROFILES } = require('../profiles.js');
53
+ const readShellConfig = require('../readShellConfig.js');
54
+ const { DEV_CLIENT_MARKER, getExclusions } = require('../shellVariant.js');
55
+
56
+ // The modules some release profile leaves out, which the dev variant links, for the --control run.
57
+ const RELEASE_EXCLUDED_MODULES = Object.assign(
58
+ {},
59
+ ...RELEASE_PROFILES.map(
60
+ (profile) => getExclusions({ shell: { profile }, variant: 'release' }).modules
61
+ )
62
+ );
63
+
64
+ // The native module names a bundle that carries a module's JavaScript holds. A module with no
65
+ // JavaScript of its own in the bundle (null) is checked by autolinking only.
66
+ function nativeNames(modules) {
67
+ return Object.values(modules).filter((name) => name !== null);
68
+ }
69
+
70
+ // Copied into each prebuild's temporary project; node_modules is linked, not copied.
71
+ const SKIPPED_IN_COPY = new Set(['android', 'coverage', 'dist', 'ios', 'node_modules', '.expo']);
72
+
73
+ const AUTOLINKING_PLATFORMS = { ios: 'apple', android: 'android' };
74
+
75
+ function readOption(argv, option) {
76
+ const index = argv.indexOf(option);
77
+ return index === -1 ? undefined : argv[index + 1];
78
+ }
79
+
80
+ function readArgs(argv) {
81
+ const platform = readOption(argv, '--platform');
82
+ const profile = readOption(argv, '--profile');
83
+ if (profile !== undefined && !RELEASE_PROFILES.includes(profile)) {
84
+ throw new Error(
85
+ `--profile should be one of ${RELEASE_PROFILES.join(', ')}. Received ${JSON.stringify(
86
+ profile
87
+ )}.`
88
+ );
89
+ }
90
+ return {
91
+ control: argv.includes('--control'),
92
+ platforms: platform === undefined ? ['ios', 'android'] : [platform],
93
+ profiles: profile === undefined ? RELEASE_PROFILES : [profile],
94
+ };
95
+ }
96
+
97
+ // The environment of a check of the shell built on one profile and variant: the shell config, set
98
+ // to that profile, written to a temporary shell.json, its icon still found where it is.
99
+ function withShellEnv({ configPath: shellConfigPath, profile, shell, variant }, callback) {
100
+ return withTemporaryDirectory(`lowdefy-shell-config-${profile}-`, (directory) => {
101
+ const configPath = path.join(directory, 'shell.json');
102
+ const icon =
103
+ shell.icon === undefined
104
+ ? undefined
105
+ : path.resolve(path.dirname(shellConfigPath), shell.icon);
106
+ fs.writeFileSync(configPath, JSON.stringify({ ...shell, icon, profile }, null, 2));
107
+ return callback({
108
+ ...process.env,
109
+ CI: '1',
110
+ LOWDEFY_SHELL_CONFIG: configPath,
111
+ LOWDEFY_SHELL_VARIANT: variant,
112
+ });
113
+ });
114
+ }
115
+
116
+ function listFiles(directory) {
117
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
118
+ const entryPath = path.join(directory, entry.name);
119
+ return entry.isDirectory() ? listFiles(entryPath) : [entryPath];
120
+ });
121
+ }
122
+
123
+ function withTemporaryDirectory(prefix, callback) {
124
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
125
+ try {
126
+ return callback(directory);
127
+ } finally {
128
+ fs.rmSync(directory, { recursive: true, force: true });
129
+ }
130
+ }
131
+
132
+ // The files of the exported bundle that hold each string, as { [string]: [file] }. Hermes bytecode
133
+ // keeps string literals as plain bytes, so they are found in .hbc files too.
134
+ function findInBundle({ env, platform, strings }) {
135
+ return withTemporaryDirectory(`lowdefy-shell-bundle-${platform}-`, (outputDir) => {
136
+ execFileSync(
137
+ path.join(shellRoot, 'node_modules', '.bin', 'expo'),
138
+ ['export', '--platform', platform, '--output-dir', outputDir, '--clear'],
139
+ { cwd: shellRoot, env, stdio: ['ignore', 'ignore', 'inherit'] }
140
+ );
141
+ const files = listFiles(outputDir).map((file) => ({
142
+ name: path.relative(outputDir, file),
143
+ bytes: fs.readFileSync(file),
144
+ }));
145
+ return Object.fromEntries(
146
+ strings.map((string) => [
147
+ string,
148
+ files.filter(({ bytes }) => bytes.includes(Buffer.from(string))).map(({ name }) => name),
149
+ ])
150
+ );
151
+ });
152
+ }
153
+
154
+ function readList(text) {
155
+ return [...text.matchAll(/'([^']+)'/g)].map((match) => match[1]);
156
+ }
157
+
158
+ // The exclusions a generated native project passes to Expo autolinking: [expo modules, react-native
159
+ // config]. iOS: the Podfile's `use_expo_modules!(exclude: [...])` and its react-native-config
160
+ // command's `--exclude` pairs. Android: settings.gradle's `expoAutolinking.exclude` and the list
161
+ // added to `rnConfigCommand`.
162
+ function readExclusions({ platform, projectDir }) {
163
+ if (platform === 'ios') {
164
+ const podfile = fs.readFileSync(path.join(projectDir, 'ios', 'Podfile'), 'utf8');
165
+ const expoModules = /use_expo_modules!\(exclude: \[([^\]]*)\]\)/.exec(podfile);
166
+ const reactNative = [...podfile.matchAll(/'--exclude', '([^']+)'/g)].map((match) => match[1]);
167
+ return { expo: expoModules ? readList(expoModules[1]) : [], reactNative };
168
+ }
169
+ const settings = fs.readFileSync(path.join(projectDir, 'android', 'settings.gradle'), 'utf8');
170
+ const expoModules = /expoAutolinking\.exclude = \[([^\]]*)\]/.exec(settings);
171
+ const reactNative = /rnConfigCommand \+ \[([^\]]*)\]/.exec(settings);
172
+ return {
173
+ expo: expoModules ? readList(expoModules[1]) : [],
174
+ reactNative: reactNative ? readList(reactNative[1]).filter((item) => item !== '--exclude') : [],
175
+ };
176
+ }
177
+
178
+ function runAutolinking({ args, exclude, projectDir }) {
179
+ const output = execFileSync(
180
+ process.execPath,
181
+ [
182
+ '--no-warnings',
183
+ '--eval',
184
+ "require('expo/bin/autolinking')",
185
+ 'expo-modules-autolinking',
186
+ ...args,
187
+ ...exclude.flatMap((name) => ['--exclude', name]),
188
+ ],
189
+ { cwd: projectDir, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
190
+ );
191
+ return JSON.parse(output);
192
+ }
193
+
194
+ // The packages the native build links: Expo modules and react-native-config dependencies.
195
+ function readLinkedModules({ platform, projectDir }) {
196
+ const exclusions = readExclusions({ platform, projectDir });
197
+ const resolved = runAutolinking({
198
+ args: ['resolve', '--json', '--platform', AUTOLINKING_PLATFORMS[platform]],
199
+ exclude: exclusions.expo,
200
+ projectDir,
201
+ });
202
+ const reactNative = runAutolinking({
203
+ args: ['react-native-config', '--json', '--platform', platform],
204
+ exclude: exclusions.reactNative,
205
+ projectDir,
206
+ });
207
+ return new Set([
208
+ ...resolved.modules.map((module) => module.packageName),
209
+ ...Object.keys(reactNative.dependencies),
210
+ ]);
211
+ }
212
+
213
+ // The permissions the native project asks for, of those every profile's modules ask for: iOS
214
+ // Info.plist usage keys and Android permissions not removed by `blockedPermissions`.
215
+ function readPermissions({ platform, projectDir }) {
216
+ if (platform === 'ios') {
217
+ const iosDir = path.join(projectDir, 'ios');
218
+ const plist = fs
219
+ .readdirSync(iosDir)
220
+ .map((name) => path.join(iosDir, name, 'Info.plist'))
221
+ .find((file) => fs.existsSync(file));
222
+ const text = fs.readFileSync(plist, 'utf8');
223
+ return new Set(
224
+ [...text.matchAll(/<key>(NS[A-Za-z]+UsageDescription)<\/key>/g)].map((m) => m[1])
225
+ );
226
+ }
227
+ const manifest = fs.readFileSync(
228
+ path.join(projectDir, 'android', 'app', 'src', 'main', 'AndroidManifest.xml'),
229
+ 'utf8'
230
+ );
231
+ return new Set(
232
+ [...manifest.matchAll(/<uses-permission [^>]*android:name="([^"]+)"[^>]*>/g)]
233
+ .filter((match) => !match[0].includes('tools:node="remove"'))
234
+ .map((match) => match[1])
235
+ );
236
+ }
237
+
238
+ function prebuild({ env, platform }) {
239
+ return withTemporaryDirectory(`lowdefy-shell-prebuild-${platform}-`, (projectDir) => {
240
+ fs.readdirSync(shellRoot)
241
+ .filter((name) => !SKIPPED_IN_COPY.has(name))
242
+ .forEach((name) => {
243
+ fs.cpSync(path.join(shellRoot, name), path.join(projectDir, name), { recursive: true });
244
+ });
245
+ fs.symlinkSync(path.join(shellRoot, 'node_modules'), path.join(projectDir, 'node_modules'));
246
+ execFileSync(
247
+ path.join(projectDir, 'node_modules', '.bin', 'expo'),
248
+ ['prebuild', '--no-install', '--platform', platform],
249
+ { cwd: projectDir, env, stdio: ['ignore', 'ignore', 'inherit'] }
250
+ );
251
+ return {
252
+ linked: readLinkedModules({ platform, projectDir }),
253
+ permissions: readPermissions({ platform, projectDir }),
254
+ };
255
+ });
256
+ }
257
+
258
+ function checkControl({ configPath, platform, shell }) {
259
+ return withShellEnv({ configPath, profile: 'dev', shell, variant: 'dev' }, (env) => {
260
+ const problems = [];
261
+ const found = findInBundle({
262
+ env,
263
+ platform,
264
+ strings: [DEV_CLIENT_MARKER, ...nativeNames(RELEASE_EXCLUDED_MODULES)],
265
+ });
266
+ Object.entries(found)
267
+ .filter(([, files]) => files.length === 0)
268
+ .forEach(([string]) => problems.push(`the dev bundle does not hold "${string}"`));
269
+ const { linked } = prebuild({ env, platform });
270
+ Object.keys(RELEASE_EXCLUDED_MODULES)
271
+ .filter((name) => !linked.has(name))
272
+ .forEach((name) => problems.push(`the dev prebuild does not link ${name}`));
273
+ return problems;
274
+ });
275
+ }
276
+
277
+ // iOS asks for permissions with Info.plist usage keys, Android with android.permission names.
278
+ function onPlatform({ permissions, platform }) {
279
+ return permissions.filter(
280
+ (permission) => permission.startsWith('android.permission.') === (platform === 'android')
281
+ );
282
+ }
283
+
284
+ function checkRelease({ configPath, platform, profile, shell }) {
285
+ const exclusions = getExclusions({ shell: { ...shell, profile }, variant: 'release' });
286
+ const excluded = {
287
+ modules: exclusions.modules,
288
+ permissions: onPlatform({ permissions: exclusions.permissions, platform }),
289
+ };
290
+ return withShellEnv({ configPath, profile, shell, variant: 'release' }, (env) => {
291
+ const problems = [];
292
+ const found = findInBundle({
293
+ env,
294
+ platform,
295
+ strings: [DEV_CLIENT_MARKER, ...nativeNames(excluded.modules)],
296
+ });
297
+ Object.entries(found)
298
+ .filter(([, files]) => files.length > 0)
299
+ .forEach(([string, files]) =>
300
+ problems.push(`the release bundle holds "${string}" (${files.join(', ')})`)
301
+ );
302
+ const { linked, permissions } = prebuild({ env, platform });
303
+ Object.keys(excluded.modules)
304
+ .filter((name) => linked.has(name))
305
+ .forEach((name) => problems.push(`the release prebuild links ${name}`));
306
+ excluded.permissions
307
+ .filter((permission) => permissions.has(permission))
308
+ .forEach((permission) =>
309
+ problems.push(`the release prebuild asks for ${permission}, which no module it links needs`)
310
+ );
311
+ return { excluded, problems };
312
+ });
313
+ }
314
+
315
+ function main() {
316
+ const { control, platforms, profiles } = readArgs(process.argv.slice(2));
317
+ const { configPath, shell } = readShellConfig();
318
+ let failed = false;
319
+ platforms.forEach((platform) => {
320
+ if (control) {
321
+ const problems = checkControl({ configPath, platform, shell });
322
+ if (problems.length > 0) {
323
+ problems.forEach((problem) => console.error(`${platform}: ${problem}.`));
324
+ failed = true;
325
+ return;
326
+ }
327
+ console.log(
328
+ `${platform}: the dev variant holds the dev-tab client and links ${Object.keys(
329
+ RELEASE_EXCLUDED_MODULES
330
+ ).join(', ')}.`
331
+ );
332
+ }
333
+ profiles.forEach((profile) => {
334
+ const { excluded, problems } = checkRelease({ configPath, platform, profile, shell });
335
+ if (problems.length > 0) {
336
+ problems.forEach((problem) => console.error(`${platform} ${profile}: ${problem}.`));
337
+ failed = true;
338
+ return;
339
+ }
340
+ const permissions =
341
+ excluded.permissions.length === 0
342
+ ? 'asks for no permission of a left-out module'
343
+ : `asks for none of ${excluded.permissions.join(', ')}`;
344
+ console.log(
345
+ `${platform} ${profile}: the release shell "${
346
+ shell.id
347
+ }" holds no dev-tab client, links none of ${Object.keys(excluded.modules).join(
348
+ ', '
349
+ )}, and ${permissions}.`
350
+ );
351
+ });
352
+ });
353
+ if (failed) {
354
+ process.exit(1);
355
+ }
356
+ }
357
+
358
+ main();
@@ -0,0 +1,67 @@
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 fs from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const shellRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const require = createRequire(import.meta.url);
24
+
25
+ const HEADER = `/*
26
+ Copyright 2020-2026 Lowdefy, Inc
27
+
28
+ Licensed under the Apache License, Version 2.0 (the "License");
29
+ you may not use this file except in compliance with the License.
30
+ You may obtain a copy of the License at
31
+
32
+ http://www.apache.org/licenses/LICENSE-2.0
33
+
34
+ Unless required by applicable law or agreed to in writing, software
35
+ distributed under the License is distributed on an "AS IS" BASIS,
36
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
37
+ See the License for the specific language governing permissions and
38
+ limitations under the License.
39
+ */
40
+
41
+ // Generated by scripts/createDayjsLocales.mjs from the dayjs package the shell carries. Do not edit.
42
+ `;
43
+
44
+ // Every locale dayjs ships, as an app may name any of them in config.i18n.locales[].dayjs. Metro
45
+ // bundles each, and a locale's data is evaluated only when the app's active locale needs it.
46
+ function createDayjsLocales() {
47
+ const localeDirectory = path.join(path.dirname(require.resolve('dayjs/package.json')), 'locale');
48
+ const ids = fs
49
+ .readdirSync(localeDirectory)
50
+ .filter((file) => file.endsWith('.js'))
51
+ .map((file) => file.slice(0, -'.js'.length))
52
+ .sort();
53
+ // Keys are quoted only where an id is not an identifier, as Prettier writes them.
54
+ const entries = ids.map((id) => {
55
+ const key = /^[a-z]+$/.test(id) ? id : `'${id}'`;
56
+ return ` ${key}: () => require('dayjs/locale/${id}.js'),`;
57
+ });
58
+ return `${HEADER}\nconst dayjsLocales = {\n${entries.join(
59
+ '\n'
60
+ )}\n};\n\nexport default dayjsLocales;\n`;
61
+ }
62
+
63
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
64
+ fs.writeFileSync(path.join(shellRoot, 'src/locale/dayjsLocales.js'), createDayjsLocales());
65
+ }
66
+
67
+ export default createDayjsLocales;
@@ -0,0 +1,54 @@
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
+ Writes a shell's Android adaptive-icon foreground from its 1024×1024 icon: the icon scaled into
19
+ the centre 66% of a transparent 1024×1024 canvas, the safe zone a launcher's mask never cuts.
20
+ app.config.ts runs it when the shell's icon has no foreground yet (the app config is evaluated
21
+ synchronously, and image code is asynchronous).
22
+
23
+ node scripts/createShellAssets.mjs <icon.png> <foreground.png>
24
+ */
25
+
26
+ import fs from 'node:fs';
27
+ import { createRequire } from 'node:module';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const Jimp = require('jimp-compact');
32
+
33
+ const SIZE = 1024;
34
+ const SAFE_ZONE = 0.66;
35
+
36
+ async function createAdaptiveIconForeground({ icon, output }) {
37
+ const image = await Jimp.read(icon);
38
+ const inner = Math.round(SIZE * SAFE_ZONE);
39
+ image.contain(inner, inner);
40
+ const canvas = new Jimp(SIZE, SIZE, 0x00000000);
41
+ canvas.composite(image, Math.round((SIZE - inner) / 2), Math.round((SIZE - inner) / 2));
42
+ await canvas.writeAsync(output);
43
+ return output;
44
+ }
45
+
46
+ if (fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
47
+ const [icon, output] = process.argv.slice(2);
48
+ if (icon === undefined || output === undefined) {
49
+ throw new Error('Usage: node scripts/createShellAssets.mjs <icon.png> <foreground.png>');
50
+ }
51
+ await createAdaptiveIconForeground({ icon, output });
52
+ }
53
+
54
+ export { createAdaptiveIconForeground };
@@ -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
+ /*
18
+ The shell's version: the @lowdefy/shell package version, which the shell sends as
19
+ `x-lowdefy-shell` and compares with an app's `mobile.minShellVersion`
20
+ (src/version/isShellTooOld.js).
21
+
22
+ Written to src/version/shellVersion.js, which the shell's JavaScript imports
23
+ (src/config/getShellConfig.js). The version travels in the JavaScript bundle, not the Expo config,
24
+ so it is the version of the code that runs: an embedded launch reads the Expo config the native
25
+ build embedded, which a shell running newer JavaScript from Metro has outgrown. Only the
26
+ version is written, not package.json, which would put every dependency name in the bundle. The
27
+ file is committed; src/version/shellVersion.test.js fails when it is stale, and
28
+ `pnpm release:version` rewrites it after the version bump.
29
+
30
+ node scripts/createShellVersion.mjs prints the version
31
+ node scripts/createShellVersion.mjs --write writes src/version/shellVersion.js
32
+
33
+ Usage: pnpm --filter=@lowdefy/shell shell-version
34
+ */
35
+
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const shellRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+
42
+ const HEADER = `/*
43
+ Copyright 2020-2026 Lowdefy, Inc
44
+
45
+ Licensed under the Apache License, Version 2.0 (the "License");
46
+ you may not use this file except in compliance with the License.
47
+ You may obtain a copy of the License at
48
+
49
+ http://www.apache.org/licenses/LICENSE-2.0
50
+
51
+ Unless required by applicable law or agreed to in writing, software
52
+ distributed under the License is distributed on an "AS IS" BASIS,
53
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
54
+ See the License for the specific language governing permissions and
55
+ limitations under the License.
56
+ */
57
+
58
+ // Generated by scripts/createShellVersion.mjs from package.json. Do not edit.
59
+ `;
60
+
61
+ function createShellVersion() {
62
+ const { version } = JSON.parse(fs.readFileSync(path.join(shellRoot, 'package.json'), 'utf8'));
63
+ return version;
64
+ }
65
+
66
+ function createShellVersionModule() {
67
+ return `${HEADER}\nconst shellVersion = ${JSON.stringify(
68
+ createShellVersion()
69
+ )};\n\nexport default shellVersion;\n`;
70
+ }
71
+
72
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
73
+ if (process.argv.includes('--write')) {
74
+ fs.writeFileSync(
75
+ path.join(shellRoot, 'src/version/shellVersion.js'),
76
+ createShellVersionModule()
77
+ );
78
+ } else {
79
+ process.stdout.write(createShellVersion());
80
+ }
81
+ }
82
+
83
+ export default createShellVersion;