General guidance
- All APIs are shared (multi-threaded)
- All objects should be reference-counted for safe destruction
- Events can be used to call APIs, making re-entrant deadlocks and infinite looping a possibility
Core Library
Make an executor for holding state as the library is built out. The host will use the executor as its primary interface, and listen for app lifecycle events.
Put the code in an executor library project, not in the root.
Institute a version file for this library. It should be bumped as part of the PR, and the PR validation should fail if the bump isn't included.
Memory-mapped files
Make an interface which represents a memory-mapped file. Open, Close, and GetData -> std::string_view. Build a Windows implementation only at this time.
This will be used primarily for reading JavaScript bundle and library files.
App registry
The host registers each app group's root directory with the executor.
The executor reads the manifest from the root directory. It builds a list of app name/version pairs, adding each one to the registry map. App name/version is the map key, and the entry is a shared app group object. The app group contains the root directory and manifest.
If any name/version pair already exists in the map, registration of the entire group fails -- without modifying anything.
Launching an app
The host launches an app using its name and version. Launching happens in two steps. First, the host gets an app instance, which is a container for all JavaScript resources. Second, the host creates a rendering surface for the app and invokes the app's JavaScript entry-point. The host can skip the second step if it only wants to "warm up" an app.
Creating an app instance
Getting an app instance is complex. The host uses name/version to find the app group in the registry. The host then queries the app group to see if it has an active app instance. If not, the host directs the group to create a new instance, passing in "runtime" props as input: dev settings and packager config.
When creating an instance, the executor first verifies that the app's JavaScript code is compatible with the host's native code. Specifically, it checks that the react-native versions are compatible (same major/minor, JS patch >= native patch), and that all required native modules are available (no version checks). The executor then initializes all native modules, optionally passing in a pointer (unowned) to the parent app instance. Finally, the executor memory-maps the JavaScript bundle & library files and creates the react-native instance around them, causing JS code to be read and parsed.
NOTE: Instance creation is slow and resource-intensive. The host is responsible for gating its use of the executor to avoid a create-race.
Starting the app
The host tells the executor to start the app using a set of initial properties.
The executor queries the instance to find out if the app is running. Attempting to start an already-running app results in an error.
The executor creates a rendering surface for the app using data in the manifest. For example, the surface could be a 'dialog' view with a specific size and optional buttons for OK and Cancel. The surface is attached to the react-native instance, making it available for render() calls.
The executor then invokes the app's JavaScript entry-point, passing in the set of initial properties from the host. This causes the app to run. All communication with the host from this point onward will be through native modules and executor events.