diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000000..ccc4c6c7f8
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+20.20.2
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
deleted file mode 100644
index 3b3a366784..0000000000
--- a/android/app/src/debug/AndroidManifest.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/java/com/shoutemapp/MainApplication.kt b/android/app/src/main/java/com/shoutemapp/MainApplication.kt
index 4f38f7316e..45151eb8d3 100644
--- a/android/app/src/main/java/com/shoutemapp/MainApplication.kt
+++ b/android/app/src/main/java/com/shoutemapp/MainApplication.kt
@@ -5,13 +5,11 @@ import androidx.multidex.MultiDexApplication
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
+import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
-import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
-import com.facebook.react.soloader.OpenSourceMergedSoMapping
-import com.facebook.soloader.SoLoader
//NativeModuleInjectionMark-mainApplication-import
@@ -46,11 +44,7 @@ class MainApplication : MultiDexApplication(), ReactApplication {
override fun onCreate() {
super.onCreate()
- SoLoader.init(this, OpenSourceMergedSoMapping)
- if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
- // If you opted-in for the New Architecture, we load the native entry point for this app.
- load()
- }
+ loadReactNative(this)
//NativeModuleInjectionMark-mainApplication-oncreate-end
}
}
diff --git a/android/build.gradle b/android/build.gradle
index 3adca08a62..614f6662ce 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -9,7 +9,7 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
- classpath('org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21')
+ classpath('org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20')
classpath("de.undercouch:gradle-download-task:4.1.2")
//NativeModuleInjectionMark-root-gradle
diff --git a/android/constants.gradle b/android/constants.gradle
index 659e110c8f..5d7e9279f4 100644
--- a/android/constants.gradle
+++ b/android/constants.gradle
@@ -1,10 +1,10 @@
project.ext {
- buildToolsVersion = "35.0.0"
+ buildToolsVersion = "36.0.0"
minSdkVersion = 24
- compileSdkVersion = 35
- targetSdkVersion = 35
+ compileSdkVersion = 36
+ targetSdkVersion = 36
ndkVersion = "27.1.12297006"
- kotlinVersion = "2.0.21"
+ kotlinVersion = "2.1.20"
supportLibVersion = '28.0.0'
googlePlayServicesVersion = '15.0.0'
diff --git a/android/gradle.properties b/android/gradle.properties
index 39a1878a03..15e3f836b0 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -38,6 +38,11 @@ newArchEnabled=false
# If set to false, you will be using JSC instead.
hermesEnabled=true
+# Use this property to enable edge-to-edge display support.
+# This allows your app to draw behind system bars for an immersive UI.
+# Note: Only works with ReactActivity and should not be used with custom Activity.
+edgeToEdgeEnabled=false
+
#NativeModuleInjectionMark-gradle-properties
#keystore settings
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
index a4b76b9530..1b33c55baa 100644
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index e0fd02028b..d4081da476 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/android/gradlew b/android/gradlew
index f3b75f3b0d..23d15a9367 100755
--- a/android/gradlew
+++ b/android/gradlew
@@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -205,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/android/gradlew.bat b/android/gradlew.bat
index 9d21a21834..dd2b8eedbd 100644
--- a/android/gradlew.bat
+++ b/android/gradlew.bat
@@ -1,3 +1,8 @@
+@REM Copyright (c) Meta Platforms, Inc. and affiliates.
+@REM
+@REM This source code is licensed under the MIT license found in the
+@REM LICENSE file in the root directory of this source tree.
+
@rem
@rem Copyright 2015 the original author or authors.
@rem
@@ -70,11 +75,11 @@ goto fail
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+set CLASSPATH=
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/core/AppBuilder.js b/core/AppBuilder.js
index 53b896789a..364c01df6d 100644
--- a/core/AppBuilder.js
+++ b/core/AppBuilder.js
@@ -1,6 +1,5 @@
/* eslint-disable max-classes-per-file */
import React, { Component, createContext } from 'react';
-import { YellowBox } from 'react-native';
import {
assertExtensionsExist,
assertNotEmpty,
@@ -15,15 +14,6 @@ const APP_CONTEXT = Symbol('appContext');
export let AppContextProvider;
-// Temporarily ignoring cyclic dependency warnings and deprecated lifecycle
-// methods until package maintainers can resolve these
-YellowBox.ignoreWarnings([
- 'Require cycle:',
- 'componentWillReceiveProps',
- 'componentWillUpdate',
- 'componentWillMount',
-]);
-
/**
* Creates an application class that represents a root
* react native component, and uses the context initialized
diff --git a/ios/ShoutemApp.xcodeproj/project.pbxproj b/ios/ShoutemApp.xcodeproj/project.pbxproj
index 29a2cd97c0..5cfce9f59c 100644
--- a/ios/ShoutemApp.xcodeproj/project.pbxproj
+++ b/ios/ShoutemApp.xcodeproj/project.pbxproj
@@ -364,7 +364,7 @@
"$(inherited)",
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include,
);
- IPHONEOS_DEPLOYMENT_TARGET = 13.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CPLUSPLUSFLAGS = (
@@ -423,7 +423,7 @@
"$(inherited)",
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include,
);
- IPHONEOS_DEPLOYMENT_TARGET = 13.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MTL_ENABLE_DEBUG_INFO = NO;
ONLY_ACTIVE_ARCH = NO;
OTHER_CPLUSPLUSFLAGS = (
diff --git a/ios/ShoutemApp/AppDelegate.swift b/ios/ShoutemApp/AppDelegate.swift
index 7366385863..b6b60df294 100644
--- a/ios/ShoutemApp/AppDelegate.swift
+++ b/ios/ShoutemApp/AppDelegate.swift
@@ -12,27 +12,48 @@ import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
//NativeModuleInjectionMark-appDelegate-import
-
+
@main
-class AppDelegate: RCTAppDelegate {
- override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
- self.moduleName = "ShoutemApp"
- self.dependencyProvider = RCTAppDependencyProvider()
-
- // You can add your custom initial props in the dictionary below.
- // They will be passed down to the ViewController used by React Native.
- self.initialProps = [:]
-
+class AppDelegate: UIResponder, UIApplicationDelegate {
+ var window: UIWindow?
+
+ var reactNativeDelegate: ReactNativeDelegate?
+ var reactNativeFactory: RCTReactNativeFactory?
+
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ let delegate = ReactNativeDelegate()
+ let factory = RCTReactNativeFactory(delegate: delegate)
+ delegate.dependencyProvider = RCTAppDependencyProvider()
+
+ reactNativeDelegate = delegate
+ reactNativeFactory = factory
+
+ window = UIWindow(frame: UIScreen.main.bounds)
+
//NativeModuleInjectionMark-appDelegate-applicationDidFinishLaunchingWithOptions
- let didFinish = super.application(application, didFinishLaunchingWithOptions: launchOptions)
+
+ factory.startReactNative(
+ withModuleName: "ShoutemApp",
+ in: window,
+ launchOptions: launchOptions
+ )
+
//NativeModuleInjectionMark-appDelegate-applicationDidFinishLaunchingWithOptions-end
- return didFinish
+
+ return true
}
-
+
+ //NativeModuleInjectionMark-appDelegate-body
+}
+
+class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
self.bundleURL()
}
-
+
override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
@@ -40,6 +61,4 @@ class AppDelegate: RCTAppDelegate {
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
-
- //NativeModuleInjectionMark-appDelegate-body
}
diff --git a/metro.config.js b/metro.config.js
index 1411248a25..a0732ae493 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -6,7 +6,11 @@
*/
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
-const exclusionList = require('metro-config/src/defaults/exclusionList');
+// Metro 0.83 (RN 0.81) gates `metro-config` behind an `exports` map, so `src/` is no
+// longer reachable - `private/*` is the supported subpath. The module is Babel-compiled,
+// hence `.default`.
+const exclusionList =
+ require('metro-config/private/defaults/exclusionList').default;
const path = require('path');
// parameters adjusted by CI scripts
diff --git a/package.json b/package.json
index 5a8bcff31b..a03228a6d2 100644
--- a/package.json
+++ b/package.json
@@ -1,20 +1,21 @@
{
"name": "@shoutem/platform",
- "version": "17.0.11",
+ "version": "18.0.0-rc.2",
"scripts": {
"android": "react-native run-android",
"build": "node scripts/build",
"bundle": "node scripts/bundle",
"configure": "node scripts/configure",
"ios": "react-native run-ios",
- "lint": "eslint .",
+ "lint": "node scripts/lint.js",
"lint-fix": "eslint . --fix",
"list-dependencies": "node scripts/platform-dependencies",
"postinstall": "node scripts/postinstall",
"setup": "cd scripts && yarn install",
"start": "react-native start",
"build-web": "NODE_ENV=production node web/esbuild.config.mjs",
- "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs"
+ "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs",
+ "lint-extensions": "eslint extensions/*/app --max-warnings 0"
},
"dependencies": {
"auto-bind": "4.0.0",
@@ -22,8 +23,8 @@
"lodash": "4.17.21",
"moment": "2.29.4",
"prop-types": "15.7.1",
- "react": "19.0.0",
- "react-native": "0.78.0",
+ "react": "19.1.4",
+ "react-native": "0.81.6",
"react-redux": "7.2.6",
"redux": "3.6.0",
"redux-action-buffer": "1.0.1",
@@ -39,11 +40,11 @@
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@babel/plugin-proposal-decorators": "7.20.13",
- "@react-native/babel-preset": "0.78.0",
- "@react-native/eslint-config": "0.78.0",
- "@react-native/metro-config": "0.78.0",
+ "@react-native/babel-preset": "0.81.6",
+ "@react-native/eslint-config": "0.81.6",
+ "@react-native/metro-config": "0.81.6",
"@shoutem/build-tools": "file:scripts/helpers",
- "@shoutem/eslint-config-react-native": "~1.0.2",
+ "@shoutem/eslint-config-react-native": "~1.1.0-rc.0",
"@welldone-software/why-did-you-render": "10.0.1",
"babel-plugin-jest-hoist": "24.9.0",
"eslint-plugin-import": "2.25.4",
@@ -58,10 +59,10 @@
"prettier": "2.8.8",
"patch-package": "6.4.7",
"react-native-asset": "2.1.1",
- "react-test-renderer": "19.0.0",
- "@react-native-community/cli": "15.0.1",
- "@react-native-community/cli-platform-android": "15.0.1",
- "@react-native-community/cli-platform-ios": "15.0.1"
+ "react-test-renderer": "19.1.0",
+ "@react-native-community/cli": "20.0.0",
+ "@react-native-community/cli-platform-android": "20.0.0",
+ "@react-native-community/cli-platform-ios": "20.0.0"
},
"eslintConfig": {
"extends": "@shoutem/react-native",
@@ -102,6 +103,6 @@
"preset": "module:react-native"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
}
}
diff --git a/package.template.json b/package.template.json
index 5a8bcff31b..a03228a6d2 100644
--- a/package.template.json
+++ b/package.template.json
@@ -1,20 +1,21 @@
{
"name": "@shoutem/platform",
- "version": "17.0.11",
+ "version": "18.0.0-rc.2",
"scripts": {
"android": "react-native run-android",
"build": "node scripts/build",
"bundle": "node scripts/bundle",
"configure": "node scripts/configure",
"ios": "react-native run-ios",
- "lint": "eslint .",
+ "lint": "node scripts/lint.js",
"lint-fix": "eslint . --fix",
"list-dependencies": "node scripts/platform-dependencies",
"postinstall": "node scripts/postinstall",
"setup": "cd scripts && yarn install",
"start": "react-native start",
"build-web": "NODE_ENV=production node web/esbuild.config.mjs",
- "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs"
+ "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs",
+ "lint-extensions": "eslint extensions/*/app --max-warnings 0"
},
"dependencies": {
"auto-bind": "4.0.0",
@@ -22,8 +23,8 @@
"lodash": "4.17.21",
"moment": "2.29.4",
"prop-types": "15.7.1",
- "react": "19.0.0",
- "react-native": "0.78.0",
+ "react": "19.1.4",
+ "react-native": "0.81.6",
"react-redux": "7.2.6",
"redux": "3.6.0",
"redux-action-buffer": "1.0.1",
@@ -39,11 +40,11 @@
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@babel/plugin-proposal-decorators": "7.20.13",
- "@react-native/babel-preset": "0.78.0",
- "@react-native/eslint-config": "0.78.0",
- "@react-native/metro-config": "0.78.0",
+ "@react-native/babel-preset": "0.81.6",
+ "@react-native/eslint-config": "0.81.6",
+ "@react-native/metro-config": "0.81.6",
"@shoutem/build-tools": "file:scripts/helpers",
- "@shoutem/eslint-config-react-native": "~1.0.2",
+ "@shoutem/eslint-config-react-native": "~1.1.0-rc.0",
"@welldone-software/why-did-you-render": "10.0.1",
"babel-plugin-jest-hoist": "24.9.0",
"eslint-plugin-import": "2.25.4",
@@ -58,10 +59,10 @@
"prettier": "2.8.8",
"patch-package": "6.4.7",
"react-native-asset": "2.1.1",
- "react-test-renderer": "19.0.0",
- "@react-native-community/cli": "15.0.1",
- "@react-native-community/cli-platform-android": "15.0.1",
- "@react-native-community/cli-platform-ios": "15.0.1"
+ "react-test-renderer": "19.1.0",
+ "@react-native-community/cli": "20.0.0",
+ "@react-native-community/cli-platform-android": "20.0.0",
+ "@react-native-community/cli-platform-ios": "20.0.0"
},
"eslintConfig": {
"extends": "@shoutem/react-native",
@@ -102,6 +103,6 @@
"preset": "module:react-native"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
}
}
diff --git a/package.template.web.json b/package.template.web.json
index 5b1bfc8cfc..3cfa63c8fe 100644
--- a/package.template.web.json
+++ b/package.template.web.json
@@ -1,17 +1,18 @@
{
"name": "@shoutem/platform",
- "version": "17.0.11",
+ "version": "18.0.0-rc.2",
"scripts": {
"build": "node scripts/build",
"bundle": "node scripts/bundle",
"configure": "node scripts/configure",
- "lint": "eslint .",
+ "lint": "node scripts/lint.js",
"lint-fix": "eslint . --fix",
"list-dependencies": "node scripts/platform-dependencies",
"postinstall": "node scripts/postinstall",
"setup": "cd scripts && yarn install",
"build-web": "NODE_ENV=production node web/esbuild.config.mjs",
- "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs"
+ "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs",
+ "lint-extensions": "eslint extensions/*/app --max-warnings 0"
},
"dependencies": {
"auto-bind": "4.0.0",
@@ -19,10 +20,9 @@
"lodash": "4.17.21",
"moment": "2.29.4",
"prop-types": "15.7.1",
- "react": "19.0.0",
- "react-dom": "19.0.0",
+ "react": "19.1.4",
+ "react-dom": "19.1.4",
"react-native-web": "0.21.2",
- "@react-native/assets-registry": "0.85.3",
"@babel/plugin-proposal-export-namespace-from": "7.18.9",
"react-redux": "7.2.6",
"redux": "3.6.0",
@@ -39,11 +39,11 @@
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@babel/plugin-proposal-decorators": "7.20.13",
- "@react-native/babel-preset": "0.78.0",
- "@react-native/eslint-config": "0.78.0",
- "@react-native/metro-config": "0.78.0",
+ "@react-native/babel-preset": "0.81.6",
+ "@react-native/eslint-config": "0.81.6",
+ "@react-native/metro-config": "0.81.6",
"@shoutem/build-tools": "file:scripts/helpers",
- "@shoutem/eslint-config-react-native": "~1.0.2",
+ "@shoutem/eslint-config-react-native": "~1.1.0-rc.0",
"@welldone-software/why-did-you-render": "10.0.1",
"babel-plugin-jest-hoist": "24.9.0",
"eslint-plugin-import": "2.25.4",
@@ -58,10 +58,10 @@
"prettier": "2.8.8",
"patch-package": "6.4.7",
"react-native-asset": "2.1.1",
- "react-test-renderer": "19.0.0",
- "@react-native-community/cli": "15.0.1",
- "@react-native-community/cli-platform-android": "15.0.1",
- "@react-native-community/cli-platform-ios": "15.0.1",
+ "react-test-renderer": "19.1.0",
+ "@react-native-community/cli": "20.0.0",
+ "@react-native-community/cli-platform-android": "20.0.0",
+ "@react-native-community/cli-platform-ios": "20.0.0",
"cssnano": "^7.0.4",
"sass": "^1.77.7",
"esbuild": "0.23.1",
@@ -112,6 +112,6 @@
"preset": "module:react-native"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
}
}
diff --git a/patches/react-native+0.72.4.patch b/patches/react-native+0.81.6.patch
similarity index 82%
rename from patches/react-native+0.72.4.patch
rename to patches/react-native+0.81.6.patch
index 1e9267d566..3cea6305f6 100644
--- a/patches/react-native+0.72.4.patch
+++ b/patches/react-native+0.81.6.patch
@@ -2,11 +2,11 @@ diff --git a/node_modules/react-native/scripts/find-node-for-xcode.sh b/node_mod
index e928cd8..4f976e4 100644
--- a/node_modules/react-native/scripts/find-node-for-xcode.sh
+++ b/node_modules/react-native/scripts/find-node-for-xcode.sh
-@@ -20,6 +20,7 @@ set -e
+@@ -19,6 +19,7 @@
# remove global prefix if it's already set
# the running shell process will choose a node binary and a global package directory breaks version managers
unset PREFIX
+unset npm_config_prefix
- # Support Homebrew on M1
- HOMEBREW_M1_BIN=/opt/homebrew/bin
+ # Support Homebrew on Apple Silicon
+ HOMEBREW_APPLE_SILICON_BIN=/opt/homebrew/bin
diff --git a/patches/react-native-screens+3.37.0.patch b/patches/react-native-screens+3.37.0.patch
deleted file mode 100644
index 13394fbb72..0000000000
--- a/patches/react-native-screens+3.37.0.patch
+++ /dev/null
@@ -1,15 +0,0 @@
-diff --git a/node_modules/react-native-screens/android/build.gradle b/node_modules/react-native-screens/android/build.gradle
-index bbca48a..39aa019 100644
---- a/node_modules/react-native-screens/android/build.gradle
-+++ b/node_modules/react-native-screens/android/build.gradle
-@@ -82,7 +82,9 @@ android {
- externalNativeBuild {
- cmake {
- arguments "-DANDROID_STL=c++_shared",
-- "-DRNS_NEW_ARCH_ENABLED=${IS_NEW_ARCHITECTURE_ENABLED}"
-+ "-DRNS_NEW_ARCH_ENABLED=${IS_NEW_ARCHITECTURE_ENABLED}",
-+ // 16KB alignment with NDK 27: https://github.com/software-mansion/react-native-screens/issues/2996#issuecomment-3214382789
-+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
- }
- }
- }
diff --git a/platform/platform.json b/platform/platform.json
index 9cc8a0185e..7c8d3cb922 100644
--- a/platform/platform.json
+++ b/platform/platform.json
@@ -1,90 +1,90 @@
{
- "version": "17.0.11",
- "releaseNotes": "Builder release notes\n* ๐ Fixed the app's web preview failing to build.\n\nDeveloper notes\n* ๐ฆ Pinned react-native to 0.85.3 via resolutions and added @react-native/assets-registry as an explicit dependency in the web package template โ react-native 0.87.0 (new npm latest) dropped @react-native/assets-registry, which broke web bundle builds.",
+ "version": "18.0.0-rc.2",
+ "releaseNotes": "Developer notes\n* ๐ค Android now compiles against and targets API level 36 (Android 16), with updated Kotlin and Gradle tooling.\n* ๐ฆ Upgraded the platform to React Native 0.81 and React 19.\n* ๐งน Platform-wide lint modernization and internal cleanup.",
"builds": {
"live": {
"excludedPackages": []
}
},
"nativeChanges": {
- "lastVersion": "17.0.9"
+ "lastVersion": "18.0.0-rc.2"
},
"dependencies": {
- "shoutem.about": "~9.1.0",
- "shoutem.advertising": "~8.1.0",
- "shoutem.affiliate": "~4.0.0",
- "shoutem.age-restriction": "~2.0.1",
- "shoutem.agora": "~7.1.0",
- "shoutem.analytics": "~5.2.0",
- "shoutem.application": "~7.6.1",
- "shoutem.audio": "~9.0.2",
- "shoutem.auth": "~11.0.3",
- "shoutem.bands-in-town": "~2.0.0",
- "shoutem.besttime": "~6.0.1",
- "shoutem.books": "~7.1.0",
- "shoutem.camera": "~6.3.0",
- "shoutem.carplay": "~2.0.0",
- "shoutem.checklist": "~6.1.0",
- "shoutem.cms": "~8.2.1",
- "shoutem.deals": "~8.1.1",
- "shoutem.events": "~6.2.1",
- "shoutem.favorites": "~7.0.1",
- "shoutem.firebase": "~7.0.0",
- "shoutem.fonts": "~3.0.0",
- "shoutem.geo-restrictions": "~5.1.3",
- "shoutem.google-analytics": "~4.1.0",
- "shoutem.i18n": "~5.0.1",
- "shoutem.ical-events": "~6.0.2",
+ "shoutem.about": "~9.1.1-rc.1",
+ "shoutem.advertising": "~8.1.1-rc.1",
+ "shoutem.affiliate": "~4.0.1-rc.1",
+ "shoutem.age-restriction": "~2.0.2-rc.1",
+ "shoutem.agora": "~7.1.1-rc.1",
+ "shoutem.analytics": "~5.2.1-rc.1",
+ "shoutem.application": "~7.6.2-rc.1",
+ "shoutem.audio": "~9.0.3-rc.1",
+ "shoutem.auth": "~11.0.4-rc.1",
+ "shoutem.bands-in-town": "~2.0.1-rc.1",
+ "shoutem.besttime": "~6.0.2-rc.1",
+ "shoutem.books": "~7.1.1-rc.1",
+ "shoutem.camera": "~6.3.1-rc.1",
+ "shoutem.carplay": "~2.0.1-rc.1",
+ "shoutem.checklist": "~6.1.1-rc.1",
+ "shoutem.cms": "~8.2.2-rc.1",
+ "shoutem.deals": "~8.1.2-rc.1",
+ "shoutem.events": "~6.2.2-rc.1",
+ "shoutem.favorites": "~7.0.2-rc.1",
+ "shoutem.firebase": "~7.0.1-rc.1",
+ "shoutem.fonts": "~3.0.1-rc.1",
+ "shoutem.geo-restrictions": "~5.1.4-rc.1",
+ "shoutem.google-analytics": "~4.1.1-rc.1",
+ "shoutem.i18n": "~5.0.2-rc.1",
+ "shoutem.ical-events": "~6.0.3-rc.1",
"shoutem.importers": "~1.0.1",
- "shoutem.in-app-purchases": "~7.3.0",
- "shoutem.interactive-faq": "~8.1.0",
- "shoutem.layouts": "~8.1.2",
- "shoutem.live-update": "~2.1.1",
- "shoutem.loyalty": "~8.1.0",
- "shoutem.menu": "~6.1.0",
- "shoutem.navigation": "~7.5.0",
- "shoutem.news": "~7.3.0",
- "shoutem.notification-center": "~12.2.2",
- "shoutem.onboarding": "~6.0.0",
- "shoutem.page": "~6.1.0",
- "shoutem.people": "~6.1.0",
- "shoutem.permissions": "~7.2.0",
- "shoutem.persist": "~5.0.0",
- "shoutem.photos": "~6.1.0",
- "shoutem.places": "~8.4.1",
- "shoutem.podcast": "~13.1.3",
- "shoutem.preview": "~7.1.1",
- "shoutem.products": "~6.1.0",
- "shoutem.push-notifications": "~5.1.1",
- "shoutem.questionnaire": "~1.1.0",
- "shoutem.radio-player": "~12.1.5",
- "shoutem.recombee": "~3.0.0",
- "shoutem.redux": "~5.0.3",
- "shoutem.rss": "~6.0.1",
+ "shoutem.in-app-purchases": "~7.3.1-rc.1",
+ "shoutem.interactive-faq": "~8.1.1-rc.1",
+ "shoutem.layouts": "~8.1.3-rc.1",
+ "shoutem.live-update": "~2.1.2-rc.1",
+ "shoutem.loyalty": "~8.1.1-rc.1",
+ "shoutem.menu": "~6.1.1-rc.1",
+ "shoutem.navigation": "~7.5.1-rc.0",
+ "shoutem.news": "~7.3.1-rc.1",
+ "shoutem.notification-center": "~12.2.3-rc.1",
+ "shoutem.onboarding": "~6.0.1-rc.1",
+ "shoutem.page": "~6.1.1-rc.1",
+ "shoutem.people": "~6.1.1-rc.1",
+ "shoutem.permissions": "~7.2.1-rc.1",
+ "shoutem.persist": "~5.0.1-rc.1",
+ "shoutem.photos": "~6.1.1-rc.1",
+ "shoutem.places": "~8.4.2-rc.1",
+ "shoutem.podcast": "~13.1.4-rc.1",
+ "shoutem.preview": "~7.1.2-rc.1",
+ "shoutem.products": "~6.1.1-rc.1",
+ "shoutem.push-notifications": "~5.1.2-rc.1",
+ "shoutem.questionnaire": "~1.1.1-rc.1",
+ "shoutem.radio-player": "~12.1.6-rc.1",
+ "shoutem.recombee": "~3.0.1-rc.1",
+ "shoutem.redux": "~5.0.4-rc.1",
+ "shoutem.rss": "~6.0.2-rc.1",
"shoutem.rss-monitoring": "~5.0.1",
- "shoutem.rss-news": "~6.0.0",
- "shoutem.rss-photos": "~6.0.1",
- "shoutem.rss-videos": "~6.0.2",
- "shoutem.rubicon-theme": "~7.1.0",
- "shoutem.sendbird": "~7.0.2",
- "shoutem.shopify": "~11.2.0",
- "shoutem.social": "~16.0.1",
+ "shoutem.rss-news": "~6.0.1-rc.1",
+ "shoutem.rss-photos": "~6.0.2-rc.1",
+ "shoutem.rss-videos": "~6.0.3-rc.1",
+ "shoutem.rubicon-theme": "~7.1.1-rc.1",
+ "shoutem.sendbird": "~7.0.3-rc.1",
+ "shoutem.shopify": "~11.2.1-rc.1",
+ "shoutem.social": "~16.0.2-rc.1",
"shoutem.sub-navigation": "~5.1.0",
- "shoutem.theme": "~9.1.10",
- "shoutem.user-profile": "~6.3.3",
- "shoutem.video": "~8.1.0",
- "shoutem.web-view": "~10.1.1",
- "shoutem.wordpress": "~6.0.1",
- "shoutem.youtube": "~6.0.1"
+ "shoutem.theme": "~9.1.11-rc.1",
+ "shoutem.user-profile": "~6.3.4-rc.1",
+ "shoutem.video": "~8.1.1-rc.1",
+ "shoutem.web-view": "~10.1.2-rc.1",
+ "shoutem.wordpress": "~6.0.2-rc.1",
+ "shoutem.youtube": "~6.0.2-rc.1"
},
"requiredDependencies": {
- "shoutem.application": "~7.6.1",
- "shoutem.i18n": "~5.0.1",
- "shoutem.layouts": "~8.1.2",
- "shoutem.navigation": "~7.5.0",
- "shoutem.redux": "~5.0.3",
+ "shoutem.application": "~7.6.2-rc.1",
+ "shoutem.i18n": "~5.0.2-rc.1",
+ "shoutem.layouts": "~8.1.3-rc.1",
+ "shoutem.navigation": "~7.5.1-rc.0",
+ "shoutem.redux": "~5.0.4-rc.1",
"shoutem.sub-navigation": "~5.1.0",
- "shoutem.theme": "~9.1.10"
+ "shoutem.theme": "~9.1.11-rc.1"
},
"archivedDependencies": [
"shoutem.flurry-analytics",
diff --git a/scripts/classes/app-binary-configurator.js b/scripts/classes/app-binary-configurator.js
index de481412d2..bcd39773da 100644
--- a/scripts/classes/app-binary-configurator.js
+++ b/scripts/classes/app-binary-configurator.js
@@ -41,18 +41,148 @@ function renamePath(oldPath, newPath) {
return fs.rename(oldPath, newPath);
}
-function downloadImage(imageUrl, savePath) {
- const assetsDir = path.resolve(rootProjectDir, 'assets');
- fs.ensureDirSync(assetsDir);
+const IMAGE_DOWNLOAD_ATTEMPTS = 3;
+const IMAGE_DOWNLOAD_RETRY_DELAY = 1000;
+/**
+ * Downloads a single image to disk. Rejects on transport errors (the `request`
+ * object is where connection-level failures such as ECONNRESET surface) as well
+ * as on write errors, and treats any non-2xx response as a failure instead of
+ * silently writing an error page to disk.
+ *
+ * @param imageUrl The image URL to download.
+ * @param savePath Destination path on disk. Removed if the download fails.
+ * @returns {Promise} Resolves with savePath once fully written.
+ */
+function downloadImageOnce(imageUrl, savePath) {
return new Promise((resolve, reject) => {
- request(imageUrl)
+ let settled = false;
+
+ const fail = err => {
+ if (settled) {
+ return;
+ }
+
+ settled = true;
+ fs.remove(savePath, () => reject(err));
+ };
+
+ const req = request(imageUrl);
+
+ // Connection-level failures (ECONNRESET, socket hang up, DNS) are emitted
+ // on the request, not on the write stream it is piped into. Without this
+ // listener node treats them as an unhandled 'error' event and aborts the
+ // whole process.
+ req.on('error', fail);
+
+ req.on('response', response => {
+ if (response.statusCode < 200 || response.statusCode > 299) {
+ req.abort();
+ fail(
+ new Error(
+ `Download of ${imageUrl} failed with status ${response.statusCode}.`,
+ ),
+ );
+ }
+ });
+
+ req
.pipe(fs.createWriteStream(savePath))
- .on('error', () => reject())
- .on('finish', () => resolve(savePath));
+ .on('error', fail)
+ .on('finish', () => {
+ if (!settled) {
+ settled = true;
+ resolve(savePath);
+ }
+ });
+ });
+}
+
+const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
+
+/**
+ * Ensures a file saved under a `.png` path actually contains PNG-encoded
+ * bytes. The Builder lets app owners upload icons in any format (JPEG is
+ * common) but always serves them under a `.png` name, and release-mode
+ * AAPT2 rejects such misnamed files with "file failed to compile". If the
+ * bytes aren't a valid PNG, the file is re-encoded in place with Jimp.
+ *
+ * @param imagePath Path to the downloaded image on disk.
+ * @returns {Promise} Resolves with imagePath once it's a valid PNG
+ * (or left untouched if it isn't a `.png` path, is already a valid PNG, or
+ * re-encoding fails).
+ */
+function ensurePngFormat(imagePath) {
+ if (!/\.png$/i.test(imagePath)) {
+ return Promise.resolve(imagePath);
+ }
+
+ return fs.readFile(imagePath).then(buffer => {
+ if (buffer.slice(0, 4).equals(PNG_MAGIC_BYTES)) {
+ return imagePath;
+ }
+
+ return Jimp.read(imagePath)
+ .then(
+ image =>
+ new Promise((resolve, reject) => {
+ image.write(imagePath, err => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ resolve(imagePath);
+ });
+ }),
+ )
+ .catch(error => {
+ // eslint-disable-next-line no-console
+ console.warn(
+ '[platform] app-binary-configurator.ensurePngFormat: could not re-encode image as PNG, leaving file as downloaded.',
+ error,
+ );
+
+ return imagePath;
+ });
});
}
+/**
+ * Downloads an image, retrying a few times when the failure looks transient.
+ *
+ * @param imageUrl The image URL to download.
+ * @param savePath Destination path on disk.
+ * @param attemptsLeft Remaining attempts, including the current one.
+ * @returns {Promise} Resolves with savePath once fully written.
+ */
+function downloadImage(
+ imageUrl,
+ savePath,
+ attemptsLeft = IMAGE_DOWNLOAD_ATTEMPTS,
+) {
+ const assetsDir = path.resolve(rootProjectDir, 'assets');
+ fs.ensureDirSync(assetsDir);
+
+ return downloadImageOnce(imageUrl, savePath)
+ .then(ensurePngFormat)
+ .catch(err => {
+ if (attemptsLeft <= 1) {
+ throw err;
+ }
+
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[platform] app-binary-configurator.downloadImage: download of ${imageUrl} failed, retrying.`,
+ err,
+ );
+
+ return new Promise(resolve =>
+ setTimeout(resolve, IMAGE_DOWNLOAD_RETRY_DELAY),
+ ).then(() => downloadImage(imageUrl, savePath, attemptsLeft - 1));
+ });
+}
+
function parsePlist(plistPath) {
const plistContent = fs.readFileSync(plistPath, 'utf8');
@@ -631,12 +761,23 @@ class AppBinaryConfigurator {
return Promise.resolve();
}
+ /**
+ * Runs the given configure step once per platform and waits for all of them.
+ * The per-platform result is returned so async steps (asset downloads) are
+ * actually awaited - otherwise their failures surface much later, detached
+ * from the step that started them.
+ *
+ * @param configureFunction Step to run, called with (settings, platform).
+ * @returns {Promise} Resolves once every platform's step settles.
+ */
runForAllPlatforms(configureFunction) {
return Promise.all(
_.map(binarySettings, (settings, platform) => {
if (_.isFunction(configureFunction)) {
- configureFunction(_.result(binarySettings, platform), platform);
+ return configureFunction(_.result(binarySettings, platform), platform);
}
+
+ return Promise.resolve();
}),
);
}
diff --git a/scripts/collect-ci-dependencies.js b/scripts/collect-ci-dependencies.js
new file mode 100644
index 0000000000..7f70ae6217
--- /dev/null
+++ b/scripts/collect-ci-dependencies.js
@@ -0,0 +1,66 @@
+/**
+ * CI-only helper: merges the platform template's and every extension's app
+ * dependencies into the root package.json so a plain `npm install` produces a
+ * dependency tree equivalent to a configured app (minus extension linking).
+ *
+ * Used by the lint GitHub Action, which runs on an un-configured checkout
+ * where the committed package.json is the slim pre-configure one โ without
+ * this, packages that babel.config.js needs (e.g. react-native-reanimated)
+ * are missing and ESLint fails to parse every file.
+ *
+ * Mirrors the merge rule of scripts/get-app-dependencies.js: extensions are
+ * processed in sorted order and a later dependency entry wins on conflicts.
+ *
+ * Intentionally dependency-free (runs before any npm install). The modified
+ * package.json is a CI artifact โ never commit it.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const projectPath = path.resolve(__dirname, '..');
+const packageJsonPath = path.join(projectPath, 'package.json');
+const templateJsonPath = path.join(projectPath, 'package.template.json');
+const extensionsPath = path.join(projectPath, 'extensions');
+
+function readJson(filePath) {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+}
+
+function collectCiDependencies() {
+ const packageJson = readJson(packageJsonPath);
+ const templateJson = readJson(templateJsonPath);
+
+ let dependencies = { ...packageJson.dependencies, ...templateJson.dependencies };
+
+ const extensionDirs = fs
+ .readdirSync(extensionsPath)
+ .filter(dir => fs.statSync(path.join(extensionsPath, dir)).isDirectory())
+ .sort();
+
+ let mergedExtensionsCount = 0;
+
+ extensionDirs.forEach(dir => {
+ const appPackageJsonPath = path.join(extensionsPath, dir, 'app', 'package.json');
+
+ if (!fs.existsSync(appPackageJsonPath)) {
+ return;
+ }
+
+ const appPackageJson = readJson(appPackageJsonPath);
+
+ if (appPackageJson.dependencies) {
+ dependencies = { ...dependencies, ...appPackageJson.dependencies };
+ mergedExtensionsCount += 1;
+ }
+ });
+
+ packageJson.dependencies = dependencies;
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
+
+ console.log(
+ `Merged ${Object.keys(dependencies).length} dependencies into package.json ` +
+ `(template + ${mergedExtensionsCount} extensions). Do not commit this file.`,
+ );
+}
+
+collectCiDependencies();
diff --git a/scripts/helpers/files.js b/scripts/helpers/files.js
index 612de6b964..60715c26b3 100644
--- a/scripts/helpers/files.js
+++ b/scripts/helpers/files.js
@@ -15,20 +15,91 @@ function writeJsonToFile(filePath, json) {
});
}
-function downloadFile(url, path) {
+const DOWNLOAD_ATTEMPTS = 3;
+const DOWNLOAD_RETRY_DELAY = 1000;
+
+/**
+ * Downloads a single file to disk. Rejects on transport errors (the `request`
+ * object is where connection-level failures such as ECONNRESET surface) as well
+ * as on write errors, and treats any non-2xx response as a failure instead of
+ * silently writing an error page to disk.
+ *
+ * @param url The file URL to download.
+ * @param path Destination path on disk. Removed if the download fails.
+ * @returns {Promise} Resolves once the file is fully written.
+ */
+function downloadFileOnce(url, path) {
return new Promise((resolve, reject) => {
- request(url)
+ let settled = false;
+
+ const fail = err => {
+ if (settled) {
+ return;
+ }
+
+ settled = true;
+ fs.remove(path, () => reject(err));
+ };
+
+ const req = request(url);
+
+ // Connection-level failures (ECONNRESET, socket hang up, DNS) are emitted
+ // on the request, not on the write stream it is piped into. Without this
+ // listener node treats them as an unhandled 'error' event and aborts the
+ // whole process.
+ req.on('error', fail);
+
+ req.on('response', response => {
+ if (response.statusCode < 200 || response.statusCode > 299) {
+ req.abort();
+ fail(
+ new Error(
+ `Download of ${url} failed with status ${response.statusCode}.`,
+ ),
+ );
+ }
+ });
+
+ req
.pipe(fs.createWriteStream(path))
+ .on('error', fail)
.on('close', () => {
- resolve();
- })
- .on('error', err => {
- fs.unlink(path);
- reject(err);
+ if (!settled) {
+ settled = true;
+ resolve();
+ }
});
});
}
+/**
+ * Downloads a file, retrying a few times when the failure looks transient.
+ * Configure issues many concurrent downloads, and remote hosts intermittently
+ * reset those connections - a retry is far cheaper than failing the whole build.
+ *
+ * @param url The file URL to download.
+ * @param path Destination path on disk.
+ * @param attemptsLeft Remaining attempts, including the current one.
+ * @returns {Promise} Resolves once the file is fully written.
+ */
+function downloadFile(url, path, attemptsLeft = DOWNLOAD_ATTEMPTS) {
+ return downloadFileOnce(url, path).catch(err => {
+ if (attemptsLeft <= 1) {
+ throw err;
+ }
+
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[platform] files.downloadFile: download of ${url} failed, retrying.`,
+ err,
+ );
+
+ return new Promise(resolve =>
+ setTimeout(resolve, DOWNLOAD_RETRY_DELAY),
+ ).then(() => downloadFile(url, path, attemptsLeft - 1));
+ });
+}
+
exports.writeFile = writeFile;
exports.writeJsonToFile = writeJsonToFile;
exports.downloadFile = downloadFile;
diff --git a/scripts/helpers/find-file-on-path.js b/scripts/helpers/find-file-on-path.js
index 1020fdff1e..4379cb13d4 100644
--- a/scripts/helpers/find-file-on-path.js
+++ b/scripts/helpers/find-file-on-path.js
@@ -4,9 +4,38 @@ const _ = require('lodash');
const glob = require('glob');
const { slash } = require('./path');
+// Directories that can contain files matching the same names as the app's own native
+// files (Pods generates its own project.pbxproj and hundreds of Info.plist files, build
+// output mirrors the app tree). Without ignoring them, a stale ios/Pods left over from a
+// previous configure wins the glob - 'ios/Pods' sorts before 'ios/ShoutemApp' - and every
+// caller silently resolves the wrong file.
+//
+// The patterns are anchored under `sourcePath` (see findFileOnPath) so only directories
+// *below* the search root are ignored. The app itself may live in a directory with one of
+// these names (build machines clone it into `build/`), and an unanchored `**/build/**`
+// would then exclude every file and make every caller resolve null.
+const IGNORED_DIRECTORIES = [
+ '**/Pods/**',
+ '**/build/**',
+ '**/node_modules/**',
+ '**/DerivedData/**',
+];
+
+/**
+ * Finds the first file matching the given name anywhere under the source path, ignoring
+ * generated and vendored directories below it so that only the app's own native files
+ * match.
+ *
+ * @param {String} fileName File name to search for; may be a glob (e.g. '*.xcworkspace').
+ * @param {String} sourcePath Directory to search recursively.
+ * @returns {String|null} Path to the first match, or null when nothing matches.
+ */
function findFileOnPath(fileName, sourcePath) {
const searchGlob = slash(`${sourcePath}/**/${fileName}`);
- const foundFiles = glob.sync(searchGlob);
+ const ignore = IGNORED_DIRECTORIES.map(pattern =>
+ slash(`${sourcePath}/${pattern}`),
+ );
+ const foundFiles = glob.sync(searchGlob, { ignore });
if (_.isEmpty(foundFiles)) {
return null;
diff --git a/scripts/lint.js b/scripts/lint.js
new file mode 100644
index 0000000000..d3bc6825d6
--- /dev/null
+++ b/scripts/lint.js
@@ -0,0 +1,62 @@
+/**
+ * Lints extension app code.
+ *
+ * Usage:
+ * npm run lint โ lint every extension's app dir
+ * npm run lint audio places โ lint only shoutem.audio and shoutem.places
+ * npm run lint shoutem.audio โ full extension names work too
+ * npm run lint audio -- --fix โ flags are passed through to eslint
+ *
+ * Extension names resolve to extensions//app; short names get the
+ * `shoutem.` prefix. Dependency-free so it runs on un-configured checkouts.
+ */
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const projectPath = path.resolve(__dirname, '..');
+const extensionsPath = path.join(projectPath, 'extensions');
+
+const args = process.argv.slice(2);
+const flags = args.filter(arg => arg.startsWith('-'));
+const names = args.filter(arg => !arg.startsWith('-'));
+
+/**
+ * Resolves a user-supplied extension name (short or full) to its app
+ * directory, exiting with a helpful message when it doesn't exist.
+ *
+ * @param {string} name Extension name, with or without the `shoutem.` prefix.
+ * @returns {string} Path to the extension's app directory, relative to root.
+ */
+function resolveExtensionAppDir(name) {
+ const candidates = name.includes('.') ? [name] : [`shoutem.${name}`, name];
+ const found = candidates.find(candidate =>
+ fs.existsSync(path.join(extensionsPath, candidate, 'app')),
+ );
+
+ if (!found) {
+ console.error(`Unknown extension: ${name} (tried ${candidates.join(', ')})`);
+ process.exit(1);
+ }
+
+ return path.join('extensions', found, 'app');
+}
+
+let targets;
+if (names.length > 0) {
+ targets = names.map(resolveExtensionAppDir);
+} else {
+ targets = fs
+ .readdirSync(extensionsPath)
+ .filter(dir => fs.existsSync(path.join(extensionsPath, dir, 'app')))
+ .sort()
+ .map(dir => path.join('extensions', dir, 'app'));
+}
+
+const result = spawnSync(
+ 'npx',
+ ['eslint', ...targets, '--max-warnings', '0', ...flags],
+ { cwd: projectPath, stdio: 'inherit' },
+);
+
+process.exit(result.status === null ? 1 : result.status);