chore(gpui): vendor the experimental native runtime

This commit is contained in:
Mohamed Boudra
2026-11-02 02:19:36 +01:00
parent e02fa9a5f8
commit 5518c25ac5
575 changed files with 129495 additions and 3 deletions
+3
View File
@@ -0,0 +1,3 @@
vendor/react-native-gpui/** linguist-vendored
packages/app/gpui/package-lock.json linguist-generated
packages/app/gpui/desktop/Cargo.lock linguist-generated
+6
View File
@@ -98,3 +98,9 @@ app.json
# Project-local Serena configuration and memories.
.serena/
# Vendored experimental React Native desktop runtime.
!vendor/
!vendor/react-native-gpui/
!vendor/react-native-gpui/**
!packages/app/gpui/app.json
+1 -1
View File
@@ -11,5 +11,5 @@
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx", "vendor/**"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"ignorePatterns": [".dev/**", "vendor/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
"categories": {
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Streetwriters
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
Repository: https://github.com/streetwriters/react-native-gpui
Revision: de11aa265e6fb36c80e8a30315bce6d34a9be7d6
License: MIT; see LICENSE and the licenses retained within packages.
Snapshot: packages/{cli,codegen,core,hermes} and
modules/{fs,gesture-handler,reanimated,safe-area-context,screens,svg,worklets}.
Examples, tester, testing package, website, and unused fonts/vector-icons ports
are not included. Upstream tests and build sources within retained packages stay
with the snapshot so contributors can inspect and modify the renderer.
Local patches (all other retained files match the revision above):
- packages/core/ReactGpui/rn-gpui/src/node_view.rs: box-none views must not
install their own pointer area. Otherwise an empty portal overlay redirects
clicks to its parent, blocking Paseo's sidebar. native.test.ts exercises this
against the actual app.
- packages/core/src-gpui/Libraries/Components/StatusBar/StatusBar.desktop.js:
desktop StatusBar implementation, because there is no mobile OS status bar.
GPUI itself remains a pinned Cargo git dependency. Hermes/React Native prebuilt
libraries are downloaded by upstream build tooling; this is not an offline or
fully source-built distribution.
+3
View File
@@ -0,0 +1,3 @@
# fs
Rust TurboModule library for React Native GPUI. The spec is in src/NativeFs.ts and uses React Native's public TurboModuleRegistry API. The implementation is in desktop/src/lib.rs and is discovered through package metadata. Import the default export from @react-native-gpui/fs and call its typed greet(name) method.
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "rn-gpui-fs"
version = "0.1.0"
edition = "2021"
[dependencies]
rn-gpui-module = { version = "0.1" }
# Dialogs with file-type filters and a default name on all three platforms, using
# the XDG portal on Linux so no GTK is required.
rfd = "0.15"
# The standard cross-platform watcher.
notify = "6"
# Well-known folders, which differ per platform in ways not worth re-deriving.
dirs = "5"
# `utimes` sets both timestamps of an existing file; `std` cannot.
filetime = "0.2"
# `mkdtemp` with Node's retry-on-collision behaviour.
tempfile = "3"
# `chmod` by descriptor, and the errno constants the error mapping reads.
libc = "0.2"
# `realpath` without the `\\?\` prefix on Windows.
dunce = "1"
[build-dependencies]
rn-gpui-module-build = { version = "0.1" }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
rn_gpui_module_build::generate(&["../src/NativeFs.ts"]);
}
@@ -0,0 +1,394 @@
//! The native pickers, Finder/Explorer reveal, and "open with the default app".
//!
//! `rfd` owns the dialogs, so the platform differences (AppKit panels, the
//! Windows common dialogs, the XDG portal on Linux) stay out of this crate. All
//! of `rfd`'s dialog entry points are blocking, which is acceptable here: the
//! module has a thread of its own (`THREAD = Own`), so a panel left open blocks
//! neither JS nor a frame.
//!
//! # Open question: dialogs on macOS and the main thread
//!
//! The design flags that macOS may require the panel to be created on the main
//! thread, and that this has to be confirmed under GPUI's run loop before it can
//! be called done. What `rfd` 0.15's blocking API actually does on macOS is
//! dispatch the panel onto the main queue when the process already has a running
//! `NSApplication`, run it inline when the caller is already on the main thread,
//! and panic when neither holds. This module is on its own thread, so the first
//! branch is the one that applies, and the call should hop to the main thread and
//! block this module's thread until the panel closes.
//!
//! That is the theory, and it is **not yet verified** under GPUI's run loop. The
//! spike is: run the app, open a dialog from JS, and check that the panel
//! appears, that no main-thread warning is logged, that the module thread
//! resumes when it closes, and that the app does not deadlock while the panel is
//! up. If any of that fails, the fallback is the one the design names: a second
//! module, `GpuiFsDialogs`, with `THREAD = Main`, holding only the two pickers;
//! the rest of this module stays off the main thread. Attaching the panel to the
//! calling window (a sheet on macOS) is a further step that needs a host
//! function exposing the active window's raw handle for `rfd`'s `set_parent`,
//! which does not exist yet either.
use std::path::{Path, PathBuf};
use std::process::Command;
use rn_gpui_module::ModuleError;
use crate::errors;
use crate::{DialogFilter, OpenDialogOptions, SaveDialogOptions};
/// The open picker. An empty list means the user cancelled, which is what the JS
/// layer expects; a cancelled dialog is not a failure.
pub fn show_open(options: OpenDialogOptions) -> Result<Vec<String>, ModuleError> {
let dialog = configured(
options.title.as_deref(),
options.default_path.as_deref(),
&options.filters,
);
let picked = if options.directory {
// A folder picker cannot return files, and `pick_folder` selects a
// single folder — rfd's blocking folder call has no multi-select mode.
// So `multiple` is honoured only as far as the platform allows: a
// caller asking for `{ directory: true, multiple: true }` gets at most
// one path, which is better than refusing the call outright.
dialog.pick_folder().into_iter().collect()
} else if options.multiple {
dialog.pick_files().unwrap_or_default()
} else {
dialog.pick_file().into_iter().collect()
};
Ok(utf8_paths(picked))
}
/// The save picker. `None` means the user cancelled; no file is created here
/// either way, the caller decides what to write.
pub fn show_save(options: SaveDialogOptions) -> Result<Option<String>, ModuleError> {
let (_, from_path) = split_default_path(options.default_path.as_deref());
// `defaultName` wins over the name implied by `defaultPath`, but a path
// ending in a file name is still the only hint a caller may give.
let name = options
.default_name
.filter(|name| !name.trim().is_empty())
.or(from_path);
let mut dialog = configured(
options.title.as_deref(),
options.default_path.as_deref(),
&options.filters,
);
if let Some(name) = name {
dialog = dialog.set_file_name(name);
}
let picked = dialog.save_file();
// The picker never writes, so a chosen name the bridge cannot represent is
// reported as "nothing chosen" rather than as an error the user has no way
// to act on; the alternative is a file the caller would try to write at a
// path JS could not have named.
Ok(picked.as_deref().and_then(utf8_path))
}
/// Shows `path` in the platform's file manager, selected where the platform has
/// a verb for it.
pub fn reveal(path: String) -> Result<(), ModuleError> {
ensure_exists(&path)?;
run(reveal_command(Path::new(&path)), &path)
}
/// Opens `path` with whatever the platform associates with it.
pub fn open_external(path: String) -> Result<(), ModuleError> {
ensure_exists(&path)?;
run(open_external_command(Path::new(&path)), &path)
}
/// A dialog carrying everything the three calls configure the same way: title,
/// starting directory, and the extension filters.
fn configured(
title: Option<&str>,
default_path: Option<&str>,
filters: &[DialogFilter],
) -> rfd::FileDialog {
let mut dialog = rfd::FileDialog::new();
if let Some(title) = title {
dialog = dialog.set_title(title);
}
if let Some(directory) = split_default_path(default_path).0 {
dialog = dialog.set_directory(directory);
}
for filter in filters {
// An empty extension list carries no information, and on some backends
// a filter is applied as an allow-list, which would hide every file.
if !filter.extensions.is_empty() {
dialog = dialog.add_filter(filter.name.as_str(), &filter.extensions);
}
}
dialog
}
/// Splits `defaultPath` into the directory a picker should open in, and the file
/// name to prefill when the path names a file rather than a folder.
///
/// The two pickers need different halves: the open dialog only starts in the
/// directory, the save dialog also offers the name.
fn split_default_path(default_path: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(raw) = default_path.map(str::trim).filter(|path| !path.is_empty()) else {
return (None, None);
};
let path = Path::new(raw);
let is_directory = match std::fs::metadata(path) {
Ok(metadata) => metadata.is_dir(),
// A save dialog is usually given a path that does not exist yet, so fall
// back to the spelling: a trailing separator promises a directory, and
// anything else is a proposed file name whose parent is the start.
Err(_) => ends_with_separator(raw),
};
if is_directory {
(Some(path.to_path_buf()), None)
} else {
(
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf),
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_string),
)
}
}
/// Whether a path is spelled as a directory on any of the platforms this module
/// runs on, so a Windows path still splits correctly when read elsewhere.
fn ends_with_separator(path: &str) -> bool {
path.ends_with('/') || path.ends_with('\\')
}
/// The picked paths as strings, dropping any the bridge cannot represent.
///
/// A Unix file name need not be UTF-8, but JS can only hold strings. Failing the
/// whole selection because one of several files has an unusual name would throw
/// away the files the user can use, so the others are returned instead.
fn utf8_paths(paths: Vec<PathBuf>) -> Vec<String> {
paths
.into_iter()
.filter_map(|path| utf8_path(&path))
.collect()
}
fn utf8_path(path: &Path) -> Option<String> {
path.to_str().map(str::to_string)
}
/// Fails with the same code `stat` would before a platform command can turn the
/// problem into an opaque non-zero exit.
fn ensure_exists(path: &str) -> Result<(), ModuleError> {
// `symlink_metadata` rather than `metadata`: a dangling symlink is still a
// row in Finder or Explorer, so revealing it is meaningful.
std::fs::symlink_metadata(path)
.map(|_| ())
.map_err(|error| errors::from_io(error, path))
}
/// A command line, kept separate from running it so the platform branches can be
/// asserted on without opening anything.
#[derive(Debug, Clone, PartialEq, Eq)]
struct CommandSpec {
program: &'static str,
args: Vec<String>,
}
/// The command that reveals `path` in the platform's file manager.
fn reveal_command(path: &Path) -> CommandSpec {
let shown = path.to_string_lossy().into_owned();
#[cfg(target_os = "macos")]
{
// `open -R` is the only thing that selects the item rather than opening
// its containing folder.
CommandSpec {
program: "open",
args: vec!["-R".to_string(), shown],
}
}
#[cfg(target_os = "windows")]
{
// `/select,<path>` is a single argument, comma included.
//
// Note: explorer.exe is known to return exit code 1 even when it does
// open the window, which the non-zero check below would report as a
// failure. That needs a check on a Windows machine; if it does bite,
// this branch should treat a successful spawn as success instead of
// reading the exit code.
CommandSpec {
program: "explorer",
args: vec![format!("/select,{shown}")],
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
// `xdg-open` has no portable "select this item" verb, so the containing
// folder is the closest equivalent. A path with no parent is its own
// directory and is opened as it is.
let folder = path.parent().unwrap_or(path);
CommandSpec {
program: "xdg-open",
args: vec![folder.to_string_lossy().into_owned()],
}
}
}
/// The command that opens `path` with the platform's default application.
fn open_external_command(path: &Path) -> CommandSpec {
let shown = path.to_string_lossy().into_owned();
#[cfg(target_os = "macos")]
{
CommandSpec {
program: "open",
args: vec![shown],
}
}
#[cfg(target_os = "windows")]
{
// `start` reads its first quoted argument as the window title, so the
// empty string is required for a path containing spaces to survive.
CommandSpec {
program: "cmd",
args: vec!["/C".to_string(), "start".to_string(), String::new(), shown],
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
CommandSpec {
program: "xdg-open",
args: vec![shown],
}
}
}
/// Runs a command and turns a refusal into a Node-shaped error.
///
/// `Command` is used without a shell, so a path is one argument no matter what
/// characters it contains; quoting and escaping are the platform's problem, not
/// a shell's.
fn run(spec: CommandSpec, path: &str) -> Result<(), ModuleError> {
let status = Command::new(spec.program)
.args(&spec.args)
.status()
// The program itself could not be started (missing, or not executable),
// which is an I/O failure on the path the caller asked about.
.map_err(|error| errors::from_io(error, path))?;
if status.success() {
return Ok(());
}
Err(errors::invalid(
"EIO",
format!(
"EIO: i/o error, {path} ({} exited with {status})",
spec.program
),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_default_path_that_names_a_file_splits_into_parent_and_name() {
let file = std::env::temp_dir().join("rn-gpui-fs-dialog-default.txt");
let (directory, name) = split_default_path(Some(&file.to_string_lossy()));
assert_eq!(directory.as_deref(), Some(std::env::temp_dir().as_path()));
assert_eq!(name.as_deref(), Some("rn-gpui-fs-dialog-default.txt"));
}
#[test]
fn a_default_path_that_names_an_existing_directory_stays_a_directory() {
let directory = std::env::temp_dir();
let (found, name) = split_default_path(Some(&directory.to_string_lossy()));
assert_eq!(found, Some(directory));
assert_eq!(name, None);
}
#[test]
fn a_bare_file_name_has_no_directory_to_open_in() {
let (directory, name) = split_default_path(Some("rn-gpui-fs-report.csv"));
assert_eq!(directory, None);
assert_eq!(name.as_deref(), Some("rn-gpui-fs-report.csv"));
}
#[test]
fn a_trailing_separator_marks_a_path_that_does_not_exist_as_a_directory() {
let missing = std::env::temp_dir().join("rn-gpui-fs-not-created");
let spelled = format!("{}/", missing.to_string_lossy());
let (directory, name) = split_default_path(Some(&spelled));
assert_eq!(directory, Some(missing));
assert_eq!(name, None);
}
#[test]
fn no_default_path_means_no_directory_and_no_name() {
assert_eq!(split_default_path(None), (None, None));
assert_eq!(split_default_path(Some(" ")), (None, None));
}
#[test]
fn reveal_targets_the_platform_file_manager() {
let spec = reveal_command(Path::new("/tmp/example.txt"));
#[cfg(target_os = "macos")]
{
assert_eq!(spec.program, "open");
assert_eq!(spec.args, ["-R", "/tmp/example.txt"]);
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
assert_eq!(spec.program, "xdg-open");
// Only the folder, because xdg-open cannot select an item in it.
assert_eq!(spec.args, ["/tmp"]);
}
#[cfg(target_os = "windows")]
{
assert_eq!(spec.program, "explorer");
assert_eq!(spec.args, ["/select,/tmp/example.txt"]);
}
}
#[test]
fn open_external_hands_the_path_to_the_default_application() {
let spec = open_external_command(Path::new("/tmp/example.txt"));
assert_eq!(
spec.args.last().map(String::as_str),
Some("/tmp/example.txt")
);
#[cfg(target_os = "macos")]
{
assert_eq!(spec.program, "open");
assert_eq!(spec.args.len(), 1);
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
assert_eq!(spec.program, "xdg-open");
#[cfg(target_os = "windows")]
{
assert_eq!(spec.program, "cmd");
// `/C start "" <path>`: the empty argument is the title `start`
// would otherwise take the path for.
assert_eq!(&spec.args[..3], ["/C", "start", ""]);
}
}
#[test]
fn a_path_with_spaces_is_still_a_single_argument() {
let spec = open_external_command(Path::new("/tmp/two words.txt"));
assert_eq!(
spec.args.last().map(String::as_str),
Some("/tmp/two words.txt")
);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! Listing, creating and removing directories, and `mkdtemp`.
//!
//! `readdir` returns every entry as a plain `{name, kind, parentPath}` object,
//! which the JS layer turns into `Dirent` instances; there is no per-directory
//! cursor to hold open because the whole listing crosses the bridge at once.
use std::fs;
use std::path::{Path, PathBuf};
use rn_gpui_module::ModuleError;
use crate::errors;
use crate::module::kind;
use crate::DirEntry;
pub async fn readdir(path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
readdir_sync(path, recursive)
}
pub async fn mkdir(path: String, recursive: bool, mode: f64) -> Result<(), ModuleError> {
create_dir(&path, recursive, mode)
}
pub async fn rm(path: String, recursive: bool, force: bool) -> Result<(), ModuleError> {
remove(&path, recursive, force)
}
pub async fn mkdtemp(prefix: String) -> Result<String, ModuleError> {
let directory = tempfile::Builder::new()
.prefix(&prefix)
.tempdir_in(std::env::temp_dir())
.map_err(|error| errors::from_io(error, &prefix))?;
// The caller asked for a directory to keep, so it must outlive this frame;
// dropping the guard would delete it again.
Ok(path_string(directory.keep()))
}
pub fn readdir_sync(path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
let entries = list(Path::new(&path))?;
if !recursive {
return Ok(entries);
}
let mut collected = entries;
// Depth-first, appending each level as it is reached. Iterative rather than
// recursive: a deep tree should cost heap, not stack.
let mut pending: Vec<PathBuf> = collected
.iter()
.filter(|entry| entry.kind == kind::DIRECTORY)
.map(|entry| Path::new(&entry.parent_path).join(&entry.name))
.collect();
while let Some(directory) = pending.pop() {
let children = list(&directory)?;
pending.extend(
children
.iter()
.filter(|entry| entry.kind == kind::DIRECTORY)
.map(|entry| Path::new(&entry.parent_path).join(&entry.name)),
);
collected.extend(children);
}
Ok(collected)
}
pub fn mkdir_sync(path: String, recursive: bool, mode: f64) -> Result<bool, ModuleError> {
create_dir(&path, recursive, mode)?;
Ok(true)
}
pub fn rm_sync(path: String, recursive: bool, force: bool) -> Result<bool, ModuleError> {
remove(&path, recursive, force)?;
Ok(true)
}
/// The direct children of `path`, in the order the file system lists them.
///
/// `DirEntry::file_type` does not follow symbolic links, so a link is reported
/// as a link rather than as whatever it points at.
fn list(path: &Path) -> Result<Vec<DirEntry>, ModuleError> {
let name = path_string(path.to_path_buf());
let iterator = fs::read_dir(path).map_err(|error| errors::from_io(error, &name))?;
let mut entries = Vec::new();
for entry in iterator {
let entry = entry.map_err(|error| errors::from_io(error, &name))?;
let file_type = entry
.file_type()
.map_err(|error| errors::from_io(error, &name))?;
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().into_owned(),
kind: entry_kind(&file_type),
parent_path: name.clone(),
});
}
Ok(entries)
}
fn entry_kind(file_type: &fs::FileType) -> f64 {
if file_type.is_file() {
kind::FILE
} else if file_type.is_dir() {
kind::DIRECTORY
} else if file_type.is_symlink() {
kind::SYMLINK
} else {
kind::OTHER
}
}
fn create_dir(path: &str, recursive: bool, mode: f64) -> Result<(), ModuleError> {
if recursive {
// `create_dir_all` succeeds on an existing directory, which is what
// Node's `{recursive: true}` promises.
return fs::create_dir_all(path).map_err(|error| errors::from_io(error, path));
}
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
let mut builder = fs::DirBuilder::new();
builder.mode(crate::module::mode(mode));
builder
.create(path)
.map_err(|error| errors::from_io(error, path))
}
// Windows has no mode for a new directory, and `create_dir` would reject
// the `DirBuilder` extension that only exists on Unix.
#[cfg(not(unix))]
{
let _ = mode;
fs::create_dir(path).map_err(|error| errors::from_io(error, path))
}
}
fn remove(path: &str, recursive: bool, force: bool) -> Result<(), ModuleError> {
// `symlink_metadata`, so a link is removed as a link instead of acting on
// its target.
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if force && errors::code_for(&error) == "ENOENT" => return Ok(()),
Err(error) => return Err(errors::from_io(error, path)),
};
if recursive && metadata.is_dir() {
return fs::remove_dir_all(path).map_err(|error| errors::from_io(error, path));
}
if metadata.is_dir() {
// `remove_dir` fails with ENOTEMPTY on Unix and a folder-not-empty error
// on Windows, which the mapping turns into the same code; removing a
// file with it reports what Node calls ENOTDIR.
fs::remove_dir(path).map_err(|error| errors::from_io(error, path))
} else {
fs::remove_file(path).map_err(|error| errors::from_io(error, path))
}
}
fn path_string(path: PathBuf) -> String {
path.to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// The async wrappers never suspend, so polling once with a no-op waker is
/// enough and keeps the tests independent of a runtime.
fn block_on<F: std::future::Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
// One poll, no loop: these helpers do their work inline, so a `Pending`
// would mean the helper is not synchronous after all, which the test
// should fail loudly on rather than spin.
match future.as_mut().poll(&mut context) {
std::task::Poll::Ready(value) => value,
std::task::Poll::Pending => panic!("the wrappers are synchronous"),
}
}
fn tempdir() -> TempDir {
TempDir::new().expect("a temporary directory")
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// The listed paths, with separators normalised to `/`.
///
/// The entries themselves carry the platform separator, which on Windows is
/// `\`; building the expected path with `/` and comparing would test the
/// separator rather than the listing.
fn names(entries: &[DirEntry]) -> Vec<String> {
let mut names: Vec<String> = entries
.iter()
.map(|entry| format!("{}/{}", entry.parent_path, entry.name).replace('\\', "/"))
.collect();
names.sort();
names
}
fn tree(root: &Path) {
fs::create_dir_all(root.join("nested").join("deep")).expect("nested");
fs::write(root.join("top.txt"), b"top").expect("seed");
fs::write(root.join("nested").join("middle.txt"), b"middle").expect("seed");
fs::write(root.join("nested").join("deep").join("leaf.txt"), b"leaf").expect("seed");
}
#[test]
fn readdir_lists_direct_children_only() {
let dir = tempdir();
tree(dir.path());
let entries = readdir_sync(text(dir.path()), false).expect("readdir");
// `names` normalises separators, so the expected paths are built from a
// normalised root rather than the platform one.
let root = text(dir.path()).replace('\\', "/");
assert_eq!(
names(&entries),
vec![format!("{root}/nested"), format!("{root}/top.txt")]
);
let top = entries
.iter()
.find(|entry| entry.name == "top.txt")
.expect("top.txt");
assert_eq!(top.kind, kind::FILE);
assert_eq!(top.parent_path, text(dir.path()));
let nested = entries
.iter()
.find(|entry| entry.name == "nested")
.expect("nested");
assert_eq!(nested.kind, kind::DIRECTORY);
}
#[test]
fn readdir_recursive_reaches_every_descendant() {
let dir = tempdir();
tree(dir.path());
let entries = block_on(readdir(text(dir.path()), true)).expect("readdir");
// Two entries per directory, three directories deep.
assert_eq!(entries.len(), 5);
let listed = names(&entries);
let root = text(dir.path()).replace('\\', "/");
assert!(listed.contains(&format!("{root}/nested/deep/leaf.txt")));
assert!(listed.contains(&format!("{root}/nested/deep")));
let deep = entries
.iter()
.find(|entry| entry.name == "deep")
.expect("deep");
assert_eq!(
deep.parent_path,
text(&dir.path().join("nested")),
"parentPath is the containing directory"
);
}
#[cfg(unix)]
#[test]
fn readdir_reports_a_link_as_a_link() {
let dir = tempdir();
let target = dir.path().join("target.txt");
fs::write(&target, b"x").expect("seed");
std::os::unix::fs::symlink(&target, dir.path().join("link.txt")).expect("symlink");
let entries = readdir_sync(text(dir.path()), false).expect("readdir");
let link = entries
.iter()
.find(|entry| entry.name == "link.txt")
.expect("link.txt");
assert_eq!(link.kind, kind::SYMLINK);
}
#[test]
fn mkdir_with_recursive_creates_the_chain_and_accepts_an_existing_dir() {
let dir = tempdir();
let nested = dir.path().join("a").join("b").join("c");
block_on(mkdir(text(&nested), true, f64::from(0o755u32))).expect("mkdir");
assert!(nested.is_dir());
let again = mkdir_sync(text(&nested), true, f64::from(0o755u32)).expect("mkdir again");
assert!(again, "recursive mkdir succeeds when the path exists");
}
#[test]
fn mkdir_without_recursive_fails_with_eexist() {
let dir = tempdir();
let path = dir.path().join("single");
assert!(mkdir_sync(text(&path), false, f64::from(0o755u32)).expect("mkdir"));
assert!(path.is_dir());
let error =
block_on(mkdir(text(&path), false, f64::from(0o755u32))).expect_err("mkdir twice");
assert_eq!(error.code.as_deref(), Some("EEXIST"));
let missing_parent = text(&dir.path().join("no").join("such").join("dir"));
let error =
mkdir_sync(missing_parent, false, f64::from(0o755u32)).expect_err("missing parent");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn rm_force_ignores_a_missing_path() {
let dir = tempdir();
let missing = text(&dir.path().join("absent"));
block_on(rm(missing.clone(), false, true)).expect("rm force");
assert!(rm_sync(missing.clone(), true, true).expect("rm sync force"));
let error = block_on(rm(missing, false, false)).expect_err("rm without force");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn rm_removes_a_file_and_a_tree() {
let dir = tempdir();
let file = dir.path().join("one.txt");
fs::write(&file, b"x").expect("seed");
block_on(rm(text(&file), false, false)).expect("rm file");
assert!(!file.exists());
let root = dir.path().join("tree");
tree(&root);
assert!(rm_sync(text(&root), true, false).expect("rm tree"));
assert!(!root.exists());
}
#[test]
fn rm_without_recursive_refuses_a_non_empty_directory() {
let dir = tempdir();
let root = dir.path().join("tree");
tree(&root);
let error = block_on(rm(text(&root), false, false)).expect_err("non-empty");
assert_eq!(error.code.as_deref(), Some("ENOTEMPTY"));
assert!(root.exists());
}
#[test]
fn mkdtemp_creates_a_random_directory_with_the_prefix() {
let created = block_on(mkdtemp("rn-gpui-fs-".to_string())).expect("mkdtemp");
let path = Path::new(&created);
let name = path
.file_name()
.expect("the returned path names a directory")
.to_string_lossy()
.into_owned();
assert!(name.starts_with("rn-gpui-fs-"), "name was {name}");
assert!(name.len() > "rn-gpui-fs-".len(), "the rest is random");
assert!(path.is_dir());
assert_eq!(path.parent(), Some(std::env::temp_dir().as_path()));
fs::remove_dir_all(path).expect("clean up");
}
#[test]
fn readdir_of_a_missing_directory_is_enoent() {
let dir = tempdir();
let error = readdir_sync(text(&dir.path().join("absent")), false).expect_err("readdir");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
}
@@ -0,0 +1,149 @@
//! Node-compatible error mapping.
//!
//! Every failure a caller sees has to look like the one Node would have raised for
//! the same call: a `code` such as `ENOENT`, and a message in Node's format.
//! `std::io::Error` gives a kind and, on Unix, a raw errno; neither is a Node code,
//! so the translation lives here once rather than at each call site.
use rn_gpui_module::ModuleError;
/// The Node code for an `std::io::Error`.
///
/// The raw OS error is consulted before the kind because several distinct errnos
/// share a kind: `EEXIST` and `ENOTEMPTY` are both "already exists"-ish, and
/// `EACCES` versus `EPERM` matter to a caller deciding whether to try again.
pub fn code_for(error: &std::io::Error) -> &'static str {
#[cfg(unix)]
if let Some(errno) = error.raw_os_error() {
let code = match errno {
libc::ENOENT => "ENOENT",
libc::EACCES => "EACCES",
libc::EPERM => "EPERM",
libc::EEXIST => "EEXIST",
libc::ENOTDIR => "ENOTDIR",
libc::EISDIR => "EISDIR",
libc::ENOTEMPTY => "ENOTEMPTY",
libc::EINVAL => "EINVAL",
libc::ELOOP => "ELOOP",
libc::ENOSPC => "ENOSPC",
libc::EROFS => "EROFS",
libc::EMFILE => "EMFILE",
libc::ENAMETOOLONG => "ENAMETOOLONG",
// Windows' share-violation has no Unix equivalent; callers treat it
// as a permission problem, which is what Node's `EPERM` means there.
_ => return code_for_kind(error.kind()),
};
return code;
}
// Windows exposes `raw_os_error` too, but its values are Win32 codes rather
// than errnos, so the kind is the better signal there, with the few Win32
// codes whose kind is not enough named below.
#[cfg(not(unix))]
if let Some(code) = win32_code(error.raw_os_error()) {
return code;
}
code_for_kind(error.kind())
}
/// The Node code for a Win32 error whose `ErrorKind` is too coarse.
///
/// `ERROR_PRIVILEGE_NOT_HELD` (1314) has no kind of its own, so it would be `EIO`.
/// Creating a symbolic link without the privilege is the case that hits it, and
/// libuv, which Node is built on, reports it as `EPERM`.
#[cfg(not(unix))]
fn win32_code(raw: Option<i32>) -> Option<&'static str> {
match raw {
Some(1314) => Some("EPERM"),
_ => None,
}
}
fn code_for_kind(kind: std::io::ErrorKind) -> &'static str {
use std::io::ErrorKind;
match kind {
ErrorKind::NotFound => "ENOENT",
ErrorKind::PermissionDenied => "EACCES",
ErrorKind::AlreadyExists => "EEXIST",
ErrorKind::InvalidInput => "EINVAL",
ErrorKind::InvalidData => "EINVAL",
ErrorKind::WriteZero => "ENOSPC",
ErrorKind::StorageFull => "ENOSPC",
ErrorKind::ReadOnlyFilesystem => "EROFS",
ErrorKind::DirectoryNotEmpty => "ENOTEMPTY",
ErrorKind::IsADirectory => "EISDIR",
ErrorKind::NotADirectory => "ENOTDIR",
_ => "EIO",
}
}
/// The human half of a Node error message, by code.
///
/// Node's own table, because the tests compare against messages and because these
/// strings are what a developer recognises when a path is wrong.
fn description_for(code: &str) -> &'static str {
match code {
"ENOENT" => "no such file or directory",
"EACCES" => "permission denied",
"EPERM" => "operation not permitted",
"EEXIST" => "file already exists",
"ENOTDIR" => "not a directory",
"EISDIR" => "illegal operation on a directory",
"ENOTEMPTY" => "directory not empty",
"EINVAL" => "invalid argument",
"ELOOP" => "too many symbolic links encountered",
"ENOSPC" => "no space left on device",
"EROFS" => "read-only file system",
"EMFILE" => "too many open files",
"ENAMETOOLONG" => "name too long",
_ => "i/o error",
}
}
/// An `std::io::Error` as the `ModuleError` JS should receive.
///
/// `syscall` and `path` are attached by the JS layer, which knows the names the
/// caller used; the native side would have to guess between `open`, `lstat` and
/// the rest for what is already a single `stat` call.
pub fn from_io(error: std::io::Error, path: &str) -> ModuleError {
let code = code_for(&error);
ModuleError::with_code(code, format!("{code}: {}, {path}", description_for(code)))
}
/// A failure that has no `std::io::Error`, such as a closed file descriptor.
pub fn invalid(code: &str, message: impl Into<String>) -> ModuleError {
ModuleError::with_code(code, message)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_is_enoent_with_nodes_message() {
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let mapped = from_io(error, "/nope");
assert_eq!(mapped.code.as_deref(), Some("ENOENT"));
assert_eq!(mapped.message, "ENOENT: no such file or directory, /nope");
}
#[test]
fn a_denied_file_is_eacces() {
let error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
assert_eq!(from_io(error, "/x").code.as_deref(), Some("EACCES"));
}
#[cfg(windows)]
#[test]
fn a_missing_symlink_privilege_is_eperm_not_eio() {
// ERROR_PRIVILEGE_NOT_HELD: what `symlink_dir` returns without developer
// mode, and what Node reports as EPERM.
let error = std::io::Error::from_raw_os_error(1314);
assert_eq!(from_io(error, "C:\\link").code.as_deref(), Some("EPERM"));
}
#[test]
fn an_unknown_failure_still_has_a_code() {
let error = std::io::Error::new(std::io::ErrorKind::Other, "odd");
assert_eq!(from_io(error, "/x").code.as_deref(), Some("EIO"));
}
}
+640
View File
@@ -0,0 +1,640 @@
//! Reading, writing, copying, truncating, permissions and times.
//!
//! The async forms are thin wrappers over the sync ones: `std::fs` is blocking
//! either way, and this module has a thread of its own, so the only difference
//! is which thread the bridge calls the function on.
//!
//! Every failure goes through `errors::from_io`, so JS receives a Node code and
//! Node's message rather than an `std::io::Error` kind.
use std::fs;
use std::io::Write as _;
use std::path::Path;
use rn_gpui_module::{ArrayBuffer, ModuleError};
use crate::errors;
use crate::module::{kind, millis, open_options, system_time};
use crate::StatResult;
pub async fn read_file(path: String) -> Result<ArrayBuffer, ModuleError> {
read_file_sync(path)
}
pub async fn read_text_file(path: String) -> Result<String, ModuleError> {
read_text_file_sync(path)
}
pub async fn write_file(
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
write_bytes(&path, data.as_slice(), flags, mode)
}
pub async fn write_text_file(
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
write_bytes(&path, data.as_bytes(), flags, mode)
}
pub async fn stat(path: String, follow_symlinks: bool) -> Result<StatResult, ModuleError> {
stat_of(&path, follow_symlinks)
}
pub async fn rename(from: String, to: String) -> Result<(), ModuleError> {
fs::rename(&from, &to).map_err(|error| errors::from_io(error, &from))
}
pub async fn copy(from: String, to: String, recursive: bool) -> Result<(), ModuleError> {
copy_path(&from, &to, recursive)
}
pub async fn truncate(path: String, length: f64) -> Result<(), ModuleError> {
let length = crate::module::length(length)?;
let file = fs::OpenOptions::new()
.write(true)
.open(&path)
.map_err(|error| errors::from_io(error, &path))?;
file.set_len(length as u64)
.map_err(|error| errors::from_io(error, &path))
}
pub async fn utimes(path: String, atime_ms: f64, mtime_ms: f64) -> Result<(), ModuleError> {
let atime = filetime::FileTime::from_system_time(system_time(atime_ms));
let mtime = filetime::FileTime::from_system_time(system_time(mtime_ms));
filetime::set_file_times(&path, atime, mtime).map_err(|error| errors::from_io(error, &path))
}
pub async fn chmod(path: String, mode: f64) -> Result<(), ModuleError> {
set_mode(&path, mode)
}
pub fn read_file_sync(path: String) -> Result<ArrayBuffer, ModuleError> {
fs::read(&path)
.map(ArrayBuffer::from)
.map_err(|error| errors::from_io(error, &path))
}
pub fn read_text_file_sync(path: String) -> Result<String, ModuleError> {
let bytes = fs::read(&path).map_err(|error| errors::from_io(error, &path))?;
// Node decodes with replacement characters instead of rejecting a file that
// is not valid UTF-8, so reading a mis-encoded file still yields text.
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub fn write_file_sync(
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
write_bytes(&path, data.as_slice(), flags, mode)?;
Ok(true)
}
pub fn write_text_file_sync(
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
write_bytes(&path, data.as_bytes(), flags, mode)?;
Ok(true)
}
/// `Ok(None)` for a path that is not there: that is what `existsSync` asks, and
/// only a missing path means "no". A permission failure is still an error.
pub fn stat_sync(path: String, follow_symlinks: bool) -> Result<Option<StatResult>, ModuleError> {
match stat_of(&path, follow_symlinks) {
Ok(result) => Ok(Some(result)),
Err(error) if error.code.as_deref() == Some("ENOENT") => Ok(None),
Err(error) => Err(error),
}
}
pub fn exists_sync(path: String) -> Result<bool, ModuleError> {
Ok(matches!(stat_sync(path, true), Ok(Some(_))))
}
pub fn rename_sync(from: String, to: String) -> Result<bool, ModuleError> {
fs::rename(&from, &to).map_err(|error| errors::from_io(error, &from))?;
Ok(true)
}
pub fn copy_file_sync(from: String, to: String) -> Result<bool, ModuleError> {
fs::copy(&from, &to).map_err(|error| errors::from_io(error, &from))?;
Ok(true)
}
/// The one place a write happens, so bytes and text share the flag and mode
/// translation exactly.
fn write_bytes(path: &str, data: &[u8], flags: f64, mode: f64) -> Result<(), ModuleError> {
let mut file = open_options(flags, crate::module::mode(mode))
.open(path)
.map_err(|error| errors::from_io(error, path))?;
file.write_all(data)
.map_err(|error| errors::from_io(error, path))
}
fn stat_of(path: &str, follow_symlinks: bool) -> Result<StatResult, ModuleError> {
let metadata = if follow_symlinks {
fs::metadata(path)
} else {
fs::symlink_metadata(path)
}
.map_err(|error| errors::from_io(error, path))?;
Ok(stat_result(&metadata))
}
/// The generated `StatResult`, filled from `std::fs::Metadata`.
///
/// Node's `Stats` names the Unix inode fields even on Windows, where they have
/// no meaning; zero is what Node itself reports there.
fn stat_result(metadata: &fs::Metadata) -> StatResult {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
kind::FILE
} else if file_type.is_dir() {
kind::DIRECTORY
} else if file_type.is_symlink() {
kind::SYMLINK
} else {
kind::OTHER
};
#[cfg(unix)]
let (mode, dev, ino, nlink, uid, gid) = {
use std::os::unix::fs::MetadataExt as _;
(
metadata.mode() as f64,
metadata.dev() as f64,
metadata.ino() as f64,
metadata.nlink() as f64,
metadata.uid() as f64,
metadata.gid() as f64,
)
};
#[cfg(not(unix))]
let (mode, dev, ino, nlink, uid, gid) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
// `ctime` is the inode change time on Unix; Windows has no such thing, and
// its creation time is the closest equivalent.
#[cfg(unix)]
let ctime_ms = {
use std::os::unix::fs::MetadataExt as _;
metadata.ctime() as f64 * 1000.0 + metadata.ctime_nsec() as f64 / 1_000_000.0
};
#[cfg(not(unix))]
let ctime_ms = metadata
.created()
.map(millis)
.unwrap_or_else(|_| metadata.modified().map(millis).unwrap_or(0.0));
// A file system that cannot report creation gives 0, as Node does, rather
// than pretending the modification time is the birth time.
let birthtime_ms = metadata.created().map(millis).unwrap_or(0.0);
StatResult {
size: metadata.len() as f64,
mode,
kind,
atime_ms: metadata.accessed().map(millis).unwrap_or(0.0),
mtime_ms: metadata.modified().map(millis).unwrap_or(0.0),
ctime_ms,
birthtime_ms,
dev,
ino,
nlink,
uid,
gid,
}
}
fn copy_path(from: &str, to: &str, recursive: bool) -> Result<(), ModuleError> {
let metadata = fs::symlink_metadata(from).map_err(|error| errors::from_io(error, from))?;
if !metadata.is_dir() {
fs::copy(from, to).map_err(|error| errors::from_io(error, from))?;
return Ok(());
}
if !recursive {
return Err(errors::invalid(
"EISDIR",
format!("EISDIR: illegal operation on a directory, {from}"),
));
}
copy_tree(Path::new(from), Path::new(to))
}
/// Refuse to copy a directory into itself or a directory beneath it.
///
/// The destination may not exist yet, so the comparison is against its nearest
/// existing ancestor: `a` into `a/b/c` has to be caught before `a/b/c` exists,
/// which is the whole point. Both sides are canonicalised so a symlinked path
/// cannot slip past the check.
///
/// Node's code and wording are reproduced rather than invented; `cpSync` reports
/// `ERR_FS_CP_EINVAL` with `Cannot copy /tmp/cpa/ to a subdirectory of self
/// /tmp/cpa/sub`, and callers match on both.
fn refuse_copy_into_self(from: &Path, to: &Path) -> Result<(), ModuleError> {
let resolve = |path: &Path| -> Option<std::path::PathBuf> {
let mut existing = path.to_path_buf();
let mut tail = Vec::new();
loop {
if let Ok(canonical) = existing.canonicalize() {
return Some(
tail.iter()
.rev()
.fold(canonical, |base, part| base.join(part)),
);
}
let name = existing.file_name().map(|name| name.to_owned())?;
tail.push(name);
if !existing.pop() {
return None;
}
}
};
let Some(source) = resolve(from) else {
return Ok(());
};
let Some(destination) = resolve(to) else {
return Ok(());
};
if destination == source || destination.starts_with(&source) {
return Err(errors::invalid(
"ERR_FS_CP_EINVAL",
format!(
"Cannot copy {} to a subdirectory of self {}",
from.display(),
to.display()
),
));
}
Ok(())
}
fn copy_tree(from: &Path, to: &Path) -> Result<(), ModuleError> {
let target = to.to_string_lossy().into_owned();
// Checked before anything is created: copying a directory into its own
// subtree would otherwise recurse forever, and the tree it built on the way
// down would have to be cleaned up by the caller.
refuse_copy_into_self(from, to)?;
fs::create_dir_all(to).map_err(|error| errors::from_io(error, &target))?;
let children = fs::read_dir(from).map_err(|error| errors::from_io(error, &source(from)))?;
for child in children {
let child = child.map_err(|error| errors::from_io(error, &source(from)))?;
let path = child.path();
let destination = to.join(child.file_name());
// `file_type` does not follow links, which keeps a link cycle in the
// source tree from turning into infinite recursion.
let file_type = child
.file_type()
.map_err(|error| errors::from_io(error, &source(&path)))?;
if file_type.is_dir() {
copy_tree(&path, &destination)?;
} else if file_type.is_symlink() {
copy_symlink(&path, &destination)?;
} else {
fs::copy(&path, &destination)
.map_err(|error| errors::from_io(error, &source(&path)))?;
}
}
Ok(())
}
#[cfg(unix)]
fn copy_symlink(from: &Path, to: &Path) -> Result<(), ModuleError> {
let target = fs::read_link(from).map_err(|error| errors::from_io(error, &source(from)))?;
std::os::unix::fs::symlink(&target, to).map_err(|error| errors::from_io(error, &source(from)))
}
/// Windows needs a privilege to create a symbolic link, so there the link is
/// followed and its target copied, which is what a caller copying a tree wants
/// to end up with anyway.
#[cfg(not(unix))]
fn copy_symlink(from: &Path, to: &Path) -> Result<(), ModuleError> {
fs::copy(from, to)
.map(|_| ())
.map_err(|error| errors::from_io(error, &source(from)))
}
fn source(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[cfg(unix)]
fn set_mode(path: &str, mode: f64) -> Result<(), ModuleError> {
use std::os::unix::fs::PermissionsExt as _;
let bits = if mode.is_finite() && mode >= 0.0 {
mode as u32 & 0o7777
} else {
0o666
};
fs::set_permissions(path, fs::Permissions::from_mode(bits))
.map_err(|error| errors::from_io(error, path))
}
/// Windows has no permission bits. The closest equivalent is the read-only
/// attribute, so a mode without a write bit marks the file read-only and any
/// other mode clears it; nothing else about the mode survives.
#[cfg(not(unix))]
fn set_mode(path: &str, mode: f64) -> Result<(), ModuleError> {
let metadata = fs::metadata(path).map_err(|error| errors::from_io(error, path))?;
let mut permissions = metadata.permissions();
permissions.set_readonly(mode.is_finite() && mode > 0.0 && mode as u32 & 0o200 == 0);
fs::set_permissions(path, permissions).map_err(|error| errors::from_io(error, path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::module::flags;
use tempfile::TempDir;
/// The async wrappers never actually suspend (they call `std::fs` directly),
/// so a single-poll executor is enough and keeps the tests free of a runtime.
fn block_on<F: std::future::Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
// One poll, no loop: these helpers do their work inline, so a `Pending`
// would mean the helper is not synchronous after all, which the test
// should fail loudly on rather than spin.
match future.as_mut().poll(&mut context) {
std::task::Poll::Ready(value) => value,
std::task::Poll::Pending => panic!("the wrappers are synchronous"),
}
}
fn tempdir() -> TempDir {
TempDir::new().expect("a temporary directory")
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// `'w'` in Node's table.
fn write_flags() -> f64 {
f64::from(flags::WRONLY | flags::CREAT | flags::TRUNC)
}
#[test]
fn text_round_trip() {
let dir = tempdir();
let path = dir.path().join("note.txt");
block_on(write_text_file(
text(&path),
"hello".to_string(),
write_flags(),
0.0,
))
.expect("write");
assert_eq!(
block_on(read_text_file(text(&path))).expect("read"),
"hello"
);
assert_eq!(
read_text_file_sync(text(&path)).expect("read sync"),
"hello"
);
assert!(
write_text_file_sync(text(&path), "again".to_string(), write_flags(), 0.0)
.expect("write sync")
);
assert_eq!(fs::read_to_string(&path).expect("on disk"), "again");
}
#[test]
fn bytes_round_trip() {
let dir = tempdir();
let path = dir.path().join("bytes.bin");
let bytes = ArrayBuffer::from(vec![0u8, 1, 254, 255]);
block_on(write_file(text(&path), bytes.clone(), write_flags(), 0.0)).expect("write");
assert_eq!(
block_on(read_file(text(&path))).expect("read").as_slice(),
&[0u8, 1, 254, 255]
);
assert_eq!(
read_file_sync(text(&path)).expect("read sync").into_vec(),
vec![0u8, 1, 254, 255]
);
}
#[test]
fn an_append_flag_keeps_the_existing_bytes() {
let dir = tempdir();
let path = dir.path().join("log.txt");
fs::write(&path, b"one").expect("seed");
block_on(write_text_file(
text(&path),
"two".to_string(),
f64::from(flags::WRONLY | flags::CREAT | flags::APPEND),
0.0,
))
.expect("append");
assert_eq!(fs::read_to_string(&path).expect("on disk"), "onetwo");
}
#[test]
fn stat_reports_kind_and_size() {
let dir = tempdir();
let file = dir.path().join("data.bin");
fs::write(&file, b"12345").expect("seed");
let stats = block_on(stat(text(&file), true)).expect("stat");
assert_eq!(stats.kind, kind::FILE);
assert_eq!(stats.size, 5.0);
assert!(stats.mtime_ms > 0.0);
#[cfg(unix)]
assert_ne!(
stats.mode as u32 & 0o777,
0,
"the mode bits come from MetadataExt"
);
let directory = block_on(stat(text(dir.path()), true)).expect("stat dir");
assert_eq!(directory.kind, kind::DIRECTORY);
}
#[cfg(unix)]
#[test]
fn stat_follows_links_only_when_asked() {
let dir = tempdir();
let target = dir.path().join("target.txt");
fs::write(&target, b"x").expect("seed");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
assert_eq!(
block_on(stat(text(&link), false)).expect("lstat").kind,
kind::SYMLINK
);
assert_eq!(
block_on(stat(text(&link), true)).expect("stat").kind,
kind::FILE
);
}
#[test]
fn stat_sync_is_none_for_a_missing_path() {
let dir = tempdir();
let missing = text(&dir.path().join("absent.txt"));
assert!(stat_sync(missing, true).expect("stat").is_none());
}
#[test]
fn exists_sync_is_false_for_a_missing_path() {
let dir = tempdir();
assert!(!exists_sync(text(&dir.path().join("absent.txt"))).expect("exists"));
assert!(exists_sync(text(dir.path())).expect("exists"));
}
#[test]
fn a_missing_file_is_enoent() {
let dir = tempdir();
let missing = text(&dir.path().join("gone.txt"));
let error = block_on(read_text_file(missing.clone())).expect_err("read");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
assert!(error
.message
.starts_with("ENOENT: no such file or directory"));
let error = read_file_sync(missing).expect_err("read sync");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn rename_moves_a_file() {
let dir = tempdir();
let from = dir.path().join("a.txt");
let to = dir.path().join("b.txt");
fs::write(&from, b"a").expect("seed");
block_on(rename(text(&from), text(&to))).expect("rename");
assert!(!from.exists());
assert_eq!(fs::read_to_string(&to).expect("on disk"), "a");
assert!(rename_sync(text(&to), text(&from)).expect("rename sync"));
assert!(from.exists());
}
#[test]
fn copy_copies_a_file() {
let dir = tempdir();
let from = dir.path().join("one.txt");
fs::write(&from, b"copy me").expect("seed");
let to = dir.path().join("two.txt");
block_on(copy(text(&from), text(&to), false)).expect("copy");
assert_eq!(fs::read_to_string(&to).expect("on disk"), "copy me");
let third = dir.path().join("three.txt");
assert!(copy_file_sync(text(&from), text(&third)).expect("copy sync"));
assert_eq!(fs::read_to_string(&third).expect("on disk"), "copy me");
}
#[test]
fn copy_copies_a_directory_tree_when_recursive() {
let dir = tempdir();
let from = dir.path().join("tree");
fs::create_dir_all(from.join("nested")).expect("tree");
fs::write(from.join("top.txt"), b"top").expect("seed");
fs::write(from.join("nested").join("deep.txt"), b"deep").expect("seed");
let to = dir.path().join("copy");
block_on(copy(text(&from), text(&to), true)).expect("copy tree");
assert_eq!(
fs::read_to_string(to.join("nested").join("deep.txt")).expect("on disk"),
"deep"
);
let error = block_on(copy(text(&from), text(&dir.path().join("nope")), false))
.expect_err("a directory without recursive");
assert_eq!(error.code.as_deref(), Some("EISDIR"));
}
#[test]
fn copying_a_directory_into_itself_is_refused() {
let dir = tempdir();
let from = dir.path().join("tree");
fs::create_dir_all(from.join("sub")).expect("tree");
fs::write(from.join("top.txt"), b"top").expect("seed");
// Into a directory beneath itself. This used to recurse until the path
// got too long, and left whatever it had built on the way down.
let into_self = from.join("sub");
let error = block_on(copy(text(&from), text(&into_self), true))
.expect_err("copy into a subdirectory of self");
assert_eq!(error.code.as_deref(), Some("ERR_FS_CP_EINVAL"));
assert!(
error.message.starts_with("Cannot copy"),
"{}",
error.message
);
// The check runs before anything is created, so the destination is still
// just the empty directory it was.
assert_eq!(
fs::read_dir(&into_self).expect("still there").count(),
0,
"nothing may be created when the copy is refused"
);
// The directory itself is the same case.
let error = block_on(copy(text(&from), text(&from), true)).expect_err("copy onto itself");
assert_eq!(error.code.as_deref(), Some("ERR_FS_CP_EINVAL"));
}
#[test]
fn truncate_changes_the_length() {
let dir = tempdir();
let path = dir.path().join("long.txt");
fs::write(&path, b"0123456789").expect("seed");
block_on(truncate(text(&path), 4.0)).expect("truncate");
assert_eq!(fs::read_to_string(&path).expect("on disk"), "0123");
}
#[test]
fn utimes_sets_both_times() {
let dir = tempdir();
let path = dir.path().join("stamp.txt");
fs::write(&path, b"x").expect("seed");
block_on(utimes(
text(&path),
1_000_000_000_000.0,
1_100_000_000_000.0,
))
.expect("utimes");
let stats = block_on(stat(text(&path), true)).expect("stat");
assert_eq!(stats.mtime_ms, 1_100_000_000_000.0);
}
#[cfg(unix)]
#[test]
fn chmod_sets_the_permission_bits() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempdir();
let path = dir.path().join("locked.txt");
fs::write(&path, b"x").expect("seed");
block_on(chmod(text(&path), f64::from(0o640u32))).expect("chmod");
let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o640);
}
}
@@ -0,0 +1,697 @@
//! Open descriptors: `open`, `read`, `write`, `fstat`, `fsync`, `ftruncate`
//! and `close`.
//!
//! A descriptor is an id from the module's table plus a clone of the file that
//! shares its offset, which is what a descriptor is to the OS. The table owns
//! the original, so dropping the module closes every handle; ids are never
//! reused, so a stale `fd` that JS kept across a `close` fails with `EBADF`
//! instead of reaching an unrelated file.
//!
//! These functions are synchronous even though the trait methods around them are
//! `async`: the module has a thread of its own, so a blocking call here never
//! blocks JS or the UI.
use std::fs::File;
use std::io::{Read as _, Seek as _, Write as _};
use rn_gpui_module::{ArrayBuffer, ModuleError};
use crate::errors;
use crate::module::{bad_fd, descriptor, flags, kind, millis, open_options};
use crate::StatResult;
pub fn open(
module: &crate::RNFsModule,
path: String,
flags: f64,
mode: f64,
) -> Result<f64, ModuleError> {
// The table owns the file; everything the caller asks of it goes through
// `module.file(fd)`, which hands back a clone sharing the same offset.
let file = open_options(flags, crate::module::mode(mode))
.open(&path)
.map_err(|error| errors::from_io(error, &path))?;
let id = module.take_fd();
// `RDONLY` is zero, so "writable" is the presence of any write bit rather
// than the absence of a read bit.
let writable = (flags as i32) & (flags::WRONLY | flags::RDWR) != 0;
module
.files
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(id, (file, writable));
Ok(f64::from(id))
}
pub fn read(
module: &crate::RNFsModule,
fd: f64,
length: f64,
position: f64,
) -> Result<ArrayBuffer, ModuleError> {
let id = descriptor(fd)?;
let length = crate::module::length(length)?;
let mut file = module.file(fd)?;
// Nothing larger than the file can be read, so the buffer is capped at what
// remains rather than at what was asked for. A caller that passes a huge
// `length` — which `readToEnd` used to do deliberately — would otherwise ask
// for that many bytes up front and get whatever the allocator did with it.
let cap = match offset(position)? {
Some(start) => file
.metadata()
.ok()
.and_then(|meta| meta.len().checked_sub(start))
.map_or(length, |remaining| length.min(remaining as usize)),
None => file
.metadata()
.ok()
.and_then(|meta| {
// A cursor read starts wherever the handle is, so the remainder
// is measured from there rather than from the start.
let current = file.stream_position().ok()?;
meta.len().checked_sub(current)
})
.map_or(length, |remaining| length.min(remaining as usize)),
};
let mut buffer = vec![0u8; cap];
let filled = match offset(position)? {
Some(start) => read_at(&mut file, &mut buffer, start, id)?,
None => read_cursor(&mut file, &mut buffer, id)?,
};
// A read that stopped at end of file is a short read, not a failure, so the
// buffer is shrunk to what the OS actually produced rather than padded.
buffer.truncate(filled);
Ok(ArrayBuffer::from(buffer))
}
pub fn write(
module: &crate::RNFsModule,
fd: f64,
data: ArrayBuffer,
position: f64,
) -> Result<f64, ModuleError> {
let id = descriptor(fd)?;
let (mut file, writable) = module.file_and_writability(fd)?;
// Checked from the flags the handle was opened with, not from what the OS
// says: a write to a read-only handle is `EBADF` in Node, `EACCES` on
// Windows and `EBADF` on Unix.
if !writable {
return Err(bad_fd(id));
}
let bytes = data.as_slice();
if bytes.is_empty() {
return Ok(0.0);
}
// Read before the borrow below: `positioned_write` needs `&mut File` so the
// Windows branch can seek, and a match guard borrowing the file would overlap.
let append_only = appends(&file);
let written = match offset(position)? {
// An append-mode descriptor appends whatever offset the caller passes:
// a cursor write does that because the file was opened with `O_APPEND`
// (or `FILE_APPEND_DATA`), and Linux also appends for a positioned
// write. Ignoring the offset keeps the platforms alike and is what
// callers of `appendFile` expect.
Some(start) if !append_only => write_at(&mut file, bytes, start, id)?,
_ => write_cursor(&mut file, bytes, id)?,
};
Ok(written as f64)
}
pub fn fstat(module: &crate::RNFsModule, fd: f64) -> Result<StatResult, ModuleError> {
let id = descriptor(fd)?;
let file = module.file(fd)?;
let metadata = file.metadata().map_err(|error| io_error(error, id))?;
Ok(stat_result(&metadata))
}
pub fn fsync(module: &crate::RNFsModule, fd: f64) -> Result<(), ModuleError> {
let id = descriptor(fd)?;
let file = module.file(fd)?;
file.sync_all().map_err(|error| io_error(error, id))
}
pub fn ftruncate(module: &crate::RNFsModule, fd: f64, length: f64) -> Result<(), ModuleError> {
let id = descriptor(fd)?;
let length = crate::module::length(length)?;
let file = module.file(fd)?;
file.set_len(length as u64)
.map_err(|error| io_error(error, id))
}
pub fn close(module: &crate::RNFsModule, fd: f64) -> Result<(), ModuleError> {
let id = descriptor(fd)?;
let mut files = module
.files
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
// Removing rather than merely dropping the entry is what makes a second
// close report `EBADF`: the id is gone, and it is never handed out again.
files.remove(&id).ok_or_else(|| bad_fd(id))?;
Ok(())
}
/// A failure from an operation on a descriptor.
///
/// The table may still know an id the kernel has already rejected — a write to a
/// read-only descriptor is the usual way — and Node reports that as `EBADF`, not
/// as a generic I/O error. Everything else goes through the shared mapping, with
/// the descriptor standing in for the path a Node message names.
fn io_error(error: std::io::Error, id: u32) -> ModuleError {
#[cfg(unix)]
if error.raw_os_error() == Some(libc::EBADF) {
return bad_fd(id);
}
errors::from_io(error, &id.to_string())
}
/// The byte offset a `position` argument names, or `None` for "the current
/// offset", which is what a negative position means to `node:fs`.
fn offset(position: f64) -> Result<Option<u64>, ModuleError> {
if !position.is_finite() {
return Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, position {position}"),
));
}
if position < 0.0 {
return Ok(None);
}
Ok(Some(position as u64))
}
/// Whether the descriptor was opened for append.
///
/// The table stores only the `File`, so the bit is read back from the descriptor
/// itself with `F_GETFL`: that reports the flag the kernel actually holds, which
/// is more reliable than tracking a second copy beside the table.
#[cfg(unix)]
fn appends(file: &File) -> bool {
use std::os::unix::io::AsRawFd as _;
// SAFETY: `file` owns a valid descriptor for the duration of the call; `F_GETFL` takes no argument.
let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) };
flags >= 0 && flags & libc::O_APPEND != 0
}
/// Windows keeps the append bit inside the handle's granted access, with no
/// portable way to read it back, so the answer is "no" here. Cursor writes still
/// append — `append(true)` opens with `FILE_APPEND_DATA`, which does that in the
/// kernel — and a write with an explicit offset lands at that offset, which is
/// what a caller using `pwrite` on Windows would expect.
#[cfg(not(unix))]
fn appends(_file: &File) -> bool {
false
}
fn read_at(file: &mut File, buffer: &mut [u8], offset: u64, id: u32) -> Result<usize, ModuleError> {
let mut filled = 0;
while filled < buffer.len() {
match positioned_read(file, &mut buffer[filled..], offset + filled as u64) {
Ok(0) => break,
Ok(count) => filled += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(io_error(error, id)),
}
}
Ok(filled)
}
fn read_cursor(file: &mut File, buffer: &mut [u8], id: u32) -> Result<usize, ModuleError> {
let mut filled = 0;
while filled < buffer.len() {
match file.read(&mut buffer[filled..]) {
Ok(0) => break,
Ok(count) => filled += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(io_error(error, id)),
}
}
Ok(filled)
}
fn write_at(file: &mut File, bytes: &[u8], offset: u64, id: u32) -> Result<usize, ModuleError> {
let mut written = 0;
while written < bytes.len() {
match positioned_write(file, &bytes[written..], offset + written as u64) {
// A regular file does not write zero bytes for a non-empty buffer;
// treating it as `EIO` keeps the loop from spinning forever.
Ok(0) => return Err(errors::invalid("EIO", format!("EIO: i/o error, {id}"))),
Ok(count) => written += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(io_error(error, id)),
}
}
Ok(written)
}
fn write_cursor(file: &mut File, bytes: &[u8], id: u32) -> Result<usize, ModuleError> {
file.write_all(bytes).map_err(|error| io_error(error, id))?;
Ok(bytes.len())
}
#[cfg(unix)]
fn positioned_read(file: &mut File, buffer: &mut [u8], offset: u64) -> std::io::Result<usize> {
use std::os::unix::fs::FileExt as _;
// `read_at` takes `&self`, but the call sites hold `&mut File` so the
// Windows branch — which has to seek — shares one signature.
(&*file).read_at(buffer, offset)
}
/// Windows' positional read, with the cursor left where it was.
///
/// `FileExt::seek_read` is documented as not affecting the cursor, but on a
/// synchronous handle — which is what `File::open` gives — the underlying
/// `ReadFile` with an `OVERLAPPED` offset keeps the file position as a side
/// effect. Node's positional forms leave the descriptor alone, and so does the
/// Unix branch above, so the position is saved and restored here. Without this a
/// positioned read moved the cursor, and a following cursor read started from the
/// wrong place.
#[cfg(windows)]
fn positioned_read(file: &mut File, buffer: &mut [u8], offset: u64) -> std::io::Result<usize> {
use std::io::Seek as _;
use std::os::windows::fs::FileExt as _;
let saved = file.stream_position();
let result = file.seek_read(buffer, offset);
if let Ok(position) = saved {
let _ = file.seek(std::io::SeekFrom::Start(position));
}
result
}
#[cfg(unix)]
fn positioned_write(file: &mut File, bytes: &[u8], offset: u64) -> std::io::Result<usize> {
use std::os::unix::fs::FileExt as _;
(&*file).write_at(bytes, offset)
}
/// Windows' positional write, with the cursor left where it was. Same reason as
/// `positioned_read` above.
#[cfg(windows)]
fn positioned_write(file: &mut File, bytes: &[u8], offset: u64) -> std::io::Result<usize> {
use std::io::Seek as _;
use std::os::windows::fs::FileExt as _;
let saved = file.stream_position();
let result = file.seek_write(bytes, offset);
if let Ok(position) = saved {
let _ = file.seek(std::io::SeekFrom::Start(position));
}
result
}
/// The generated `StatResult`, filled from `std::fs::Metadata`.
///
/// `files::stat` builds the same shape for a path. The two are deliberately
/// separate: `files` and `handles` are independent, and twenty duplicated lines
/// cost less than one file reaching into the other's internals.
fn stat_result(metadata: &std::fs::Metadata) -> StatResult {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
kind::FILE
} else if file_type.is_dir() {
kind::DIRECTORY
} else if file_type.is_symlink() {
kind::SYMLINK
} else {
kind::OTHER
};
// Node's `Stats` names the Unix inode fields even on Windows, where they
// have no meaning; zero is what Node reports there too.
#[cfg(unix)]
let (mode, dev, ino, nlink, uid, gid) = {
use std::os::unix::fs::MetadataExt as _;
(
metadata.mode() as f64,
metadata.dev() as f64,
metadata.ino() as f64,
metadata.nlink() as f64,
metadata.uid() as f64,
metadata.gid() as f64,
)
};
#[cfg(not(unix))]
let (mode, dev, ino, nlink, uid, gid) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
// `ctime` is the inode change time on Unix; Windows has no equivalent, and
// its creation time is the closest thing it has.
#[cfg(unix)]
let ctime_ms = {
use std::os::unix::fs::MetadataExt as _;
metadata.ctime() as f64 * 1000.0 + metadata.ctime_nsec() as f64 / 1_000_000.0
};
#[cfg(not(unix))]
let ctime_ms = metadata
.created()
.map(millis)
.unwrap_or_else(|_| metadata.modified().map(millis).unwrap_or(0.0));
// A file system that cannot report creation gives 0, as Node does, rather
// than pretending the modification time is the birth time.
StatResult {
size: metadata.len() as f64,
mode,
kind,
atime_ms: metadata.accessed().map(millis).unwrap_or(0.0),
mtime_ms: metadata.modified().map(millis).unwrap_or(0.0),
ctime_ms,
birthtime_ms: metadata.created().map(millis).unwrap_or(0.0),
dev,
ino,
nlink,
uid,
gid,
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::Arc;
use rn_gpui_module::{EventEmitter, ModuleInstance, Value};
use tempfile::TempDir;
use super::*;
use crate::native_fs_module;
/// The async trait methods never actually suspend — they call these helpers
/// directly — so a single-poll executor drives the generated glue without a
/// runtime.
fn block_on<F: std::future::Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
loop {
match future.as_mut().poll(&mut context) {
std::task::Poll::Ready(value) => return value,
std::task::Poll::Pending => std::thread::yield_now(),
}
}
}
/// `NativeFsEvents` has a private emitter and no constructor, so a test
/// cannot call `RNFsModule::create` itself. Building the instance through the
/// descriptor the bridge uses is the supported way in, and it exercises the
/// generated glue as a bonus.
fn module() -> Arc<dyn ModuleInstance> {
(native_fs_module::<crate::RNFsModule>().create)(EventEmitter::new(1))
}
fn index(name: &str) -> usize {
native_fs_module::<crate::RNFsModule>()
.methods
.iter()
.position(|method| method.name == name)
.unwrap_or_else(|| panic!("the spec has no method {name}"))
}
fn call(
module: &Arc<dyn ModuleInstance>,
name: &str,
args: Vec<Value>,
) -> Result<Value, ModuleError> {
block_on(module.clone().call_promise(index(name), args)?)
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn open_fd(module: &Arc<dyn ModuleInstance>, path: &Path, flags_value: i32) -> f64 {
let value = call(
module,
"open",
vec![
Value::String(text(path)),
Value::Number(f64::from(flags_value)),
Value::Number(0.0),
],
)
.expect("open");
match value {
Value::Number(fd) => fd,
other => panic!("expected a descriptor, got {}", other.type_name()),
}
}
fn read_bytes(
module: &Arc<dyn ModuleInstance>,
fd: f64,
length: f64,
position: f64,
) -> Result<Vec<u8>, ModuleError> {
match call(
module,
"read",
vec![
Value::Number(fd),
Value::Number(length),
Value::Number(position),
],
)? {
Value::Buffer(bytes) => Ok(bytes),
other => panic!("expected bytes, got {}", other.type_name()),
}
}
fn write_bytes(
module: &Arc<dyn ModuleInstance>,
fd: f64,
bytes: &[u8],
position: f64,
) -> Result<f64, ModuleError> {
match call(
module,
"write",
vec![
Value::Number(fd),
Value::Buffer(bytes.to_vec()),
Value::Number(position),
],
)? {
Value::Number(written) => Ok(written),
other => panic!("expected a count, got {}", other.type_name()),
}
}
fn stat_size(module: &Arc<dyn ModuleInstance>, fd: f64) -> f64 {
let value = call(module, "fstat", vec![Value::Number(fd)]).expect("fstat");
let Value::Object(fields) = value else {
panic!("expected a stat object");
};
fields
.iter()
.find(|(name, _)| name == "size")
.and_then(|(_, value)| match value {
Value::Number(size) => Some(*size),
_ => None,
})
.expect("a size field")
}
fn close_fd(module: &Arc<dyn ModuleInstance>, fd: f64) -> Result<Value, ModuleError> {
call(module, "close", vec![Value::Number(fd)])
}
/// Open a fresh file containing `contents` and hand back its descriptor.
fn opened(module: &Arc<dyn ModuleInstance>, dir: &TempDir, contents: &str) -> (String, f64) {
let path = dir.path().join("data.txt");
std::fs::write(&path, contents).expect("write the fixture file");
let fd = open_fd(module, &path, flags::RDWR);
(text(&path), fd)
}
#[test]
fn a_handle_reads_back_what_it_wrote() {
let dir = TempDir::new().expect("a temporary directory");
let path = dir.path().join("note.txt");
let module = module();
let fd = open_fd(&module, &path, flags::WRONLY | flags::CREAT | flags::TRUNC);
assert_eq!(
write_bytes(&module, fd, b"hello", -1.0).expect("write"),
5.0
);
assert_eq!(stat_size(&module, fd), 5.0);
close_fd(&module, fd).expect("close");
let fd = open_fd(&module, &path, flags::RDONLY);
assert_eq!(read_bytes(&module, fd, 5.0, -1.0).expect("read"), b"hello");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_positioned_read_leaves_the_cursor_alone() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abcdef");
assert_eq!(read_bytes(&module, fd, 2.0, 0.0).expect("read"), b"ab");
// The cursor never moved, so a cursor read starts at the beginning.
assert_eq!(read_bytes(&module, fd, 2.0, -1.0).expect("read"), b"ab");
assert_eq!(read_bytes(&module, fd, 2.0, -1.0).expect("read"), b"cd");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_positioned_write_only_touches_its_range() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (path, fd) = opened(&module, &dir, "abcdef");
assert_eq!(write_bytes(&module, fd, b"XY", 2.0).expect("write"), 2.0);
assert_eq!(stat_size(&module, fd), 6.0);
assert_eq!(std::fs::read(&path).expect("on disk"), b"abXYef");
close_fd(&module, fd).expect("close");
}
#[test]
fn reading_past_the_end_is_a_short_read_not_an_error() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
// Two bytes asked for, one left: the buffer holds exactly one byte.
assert_eq!(read_bytes(&module, fd, 2.0, 2.0).expect("read"), b"c");
// Nothing left at all: an empty buffer, still a success.
assert_eq!(read_bytes(&module, fd, 8.0, 10.0).expect("read"), b"");
assert_eq!(read_bytes(&module, fd, 8.0, -1.0).expect("read"), b"abc");
assert_eq!(read_bytes(&module, fd, 8.0, -1.0).expect("read"), b"");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_huge_length_only_allocates_what_the_file_holds() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "0123456789");
// The buffer is capped at what remains, so this returns rather than
// trying to allocate four gigabytes. Finishing at all is the check: an
// allocation of `u32::MAX` bytes would abort the test process instead.
let bytes = read_bytes(&module, fd, u32::MAX as f64, 0.0).expect("read");
assert_eq!(bytes, b"0123456789");
close_fd(&module, fd).expect("close");
}
#[test]
fn appending_ignores_the_position() {
let dir = TempDir::new().expect("a temporary directory");
let path = dir.path().join("log.txt");
std::fs::write(&path, b"ab").expect("write the fixture file");
let module = module();
let fd = open_fd(&module, &path, flags::APPEND | flags::WRONLY | flags::CREAT);
write_bytes(&module, fd, b"cd", -1.0).expect("append");
// An append-mode descriptor appends even when an offset is passed, which
// is what the kernel does on Linux and what `appendFile` means.
write_bytes(&module, fd, b"ef", 0.0).expect("append");
assert_eq!(stat_size(&module, fd), 6.0);
close_fd(&module, fd).expect("close");
assert_eq!(std::fs::read(&path).expect("on disk"), b"abcdef");
}
#[test]
fn truncating_by_descriptor_changes_the_size() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abcdef");
call(
&module,
"ftruncate",
vec![Value::Number(fd), Value::Number(3.0)],
)
.expect("ftruncate");
assert_eq!(stat_size(&module, fd), 3.0);
assert_eq!(read_bytes(&module, fd, 16.0, 0.0).expect("read"), b"abc");
close_fd(&module, fd).expect("close");
}
#[test]
fn syncing_a_handle_succeeds() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
write_bytes(&module, fd, b"d", 3.0).expect("write");
call(&module, "fsync", vec![Value::Number(fd)]).expect("fsync");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_second_close_is_ebadf() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
close_fd(&module, fd).expect("the first close");
let error = close_fd(&module, fd).expect_err("the second close");
assert_eq!(error.code.as_deref(), Some("EBADF"));
}
#[test]
fn a_closed_descriptor_is_ebadf_for_every_op() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
close_fd(&module, fd).expect("close");
let error = read_bytes(&module, fd, 1.0, -1.0).expect_err("read");
assert_eq!(error.code.as_deref(), Some("EBADF"));
let error = write_bytes(&module, fd, b"x", -1.0).expect_err("write");
assert_eq!(error.code.as_deref(), Some("EBADF"));
let error = call(&module, "fstat", vec![Value::Number(fd)]).expect_err("fstat");
assert_eq!(error.code.as_deref(), Some("EBADF"));
}
#[test]
fn opening_a_missing_file_is_enoent() {
let dir = TempDir::new().expect("a temporary directory");
let path = dir.path().join("gone.txt");
let module = module();
let error = call(
&module,
"open",
vec![
Value::String(text(&path)),
Value::Number(f64::from(flags::RDONLY)),
Value::Number(0.0),
],
)
.expect_err("open");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
/// A descriptor the table still knows but the kernel rejects: Node says
/// `EBADF` here, and so must this module, rather than a generic `EIO`.
#[test]
fn writing_through_a_read_only_descriptor_is_ebadf() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let path = dir.path().join("data.txt");
std::fs::write(&path, b"abc").expect("write the fixture file");
let fd = open_fd(&module, &path, flags::RDONLY);
let error = write_bytes(&module, fd, b"x", -1.0).expect_err("write");
assert_eq!(error.code.as_deref(), Some("EBADF"));
// Reading is still fine, and the file is untouched.
assert_eq!(read_bytes(&module, fd, 3.0, 0.0).expect("read"), b"abc");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_non_finite_position_is_einval() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
let error = read_bytes(&module, fd, 1.0, f64::NAN).expect_err("read");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
let error = write_bytes(&module, fd, b"x", f64::INFINITY).expect_err("write");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
close_fd(&module, fd).expect("close");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! `@react-native-gpui/fs`: a Node-shaped file API for the desktop app.
//!
//! The TypeScript spec in `../src/NativeFs.ts` is the contract; the generator
//! turns it into the `NativeFsSpec` trait implemented here. That trait is the
//! only thing the C++ bridge knows about, so the API surface is edited there and
//! regenerated, never in this file.
//!
//! Rust needs a trait's methods in a single `impl` block, so this file holds that
//! block and hands each method to a helper in the file that owns the concern:
//!
//! - `files` — reading, writing, copying, truncating, permissions and times
//! - `dirs` — listing, creating, removing and `mkdtemp`
//! - `handles` — open descriptors, positioned reads and writes
//! - `links` — symbolic links, `realpath` and `access`
//! - `paths` — the well-known folders, created on first read
//! - `dialogs` — the pickers, `reveal` and `openExternal`
//! - `watching` — the watcher behind `watch` and `unwatch`
//!
//! Keeping the bodies in those files is what makes the crate readable; keeping
//! the `impl` here is what makes it compile.
rn_gpui_module::include_specs!();
mod dialogs;
mod dirs;
mod errors;
mod files;
mod handles;
mod links;
mod module;
mod paths;
mod watching;
pub use module::RNFsModule;
use rn_gpui_module::{ArrayBuffer, ModuleError};
impl NativeFsSpec for RNFsModule {
fn new(events: NativeFsEvents) -> Self {
Self::create(events)
}
async fn read_file(&self, path: String) -> Result<ArrayBuffer, ModuleError> {
files::read_file(path).await
}
async fn read_text_file(&self, path: String) -> Result<String, ModuleError> {
files::read_text_file(path).await
}
async fn write_file(
&self,
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
files::write_file(path, data, flags, mode).await
}
async fn write_text_file(
&self,
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
files::write_text_file(path, data, flags, mode).await
}
async fn readdir(&self, path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
dirs::readdir(path, recursive).await
}
async fn stat(&self, path: String, follow_symlinks: bool) -> Result<StatResult, ModuleError> {
files::stat(path, follow_symlinks).await
}
async fn mkdir(&self, path: String, recursive: bool, mode: f64) -> Result<(), ModuleError> {
dirs::mkdir(path, recursive, mode).await
}
async fn rm(&self, path: String, recursive: bool, force: bool) -> Result<(), ModuleError> {
dirs::rm(path, recursive, force).await
}
async fn rename(&self, from: String, to: String) -> Result<(), ModuleError> {
files::rename(from, to).await
}
async fn copy(&self, from: String, to: String, recursive: bool) -> Result<(), ModuleError> {
files::copy(from, to, recursive).await
}
async fn access(&self, path: String, mode: f64) -> Result<(), ModuleError> {
links::access(path, mode)
}
async fn realpath(&self, path: String) -> Result<String, ModuleError> {
links::realpath(path)
}
async fn readlink(&self, path: String) -> Result<String, ModuleError> {
links::readlink(path)
}
async fn symlink(
&self,
target: String,
path: String,
kind: Option<String>,
) -> Result<(), ModuleError> {
links::symlink(target, path, kind.as_deref())
}
async fn truncate(&self, path: String, length: f64) -> Result<(), ModuleError> {
files::truncate(path, length).await
}
async fn utimes(&self, path: String, atime_ms: f64, mtime_ms: f64) -> Result<(), ModuleError> {
files::utimes(path, atime_ms, mtime_ms).await
}
async fn chmod(&self, path: String, mode: f64) -> Result<(), ModuleError> {
files::chmod(path, mode).await
}
async fn mkdtemp(&self, prefix: String) -> Result<String, ModuleError> {
dirs::mkdtemp(prefix).await
}
async fn open(&self, path: String, flags: f64, mode: f64) -> Result<f64, ModuleError> {
handles::open(self, path, flags, mode)
}
async fn read(&self, fd: f64, length: f64, position: f64) -> Result<ArrayBuffer, ModuleError> {
handles::read(self, fd, length, position)
}
async fn write(&self, fd: f64, data: ArrayBuffer, position: f64) -> Result<f64, ModuleError> {
handles::write(self, fd, data, position)
}
async fn fstat(&self, fd: f64) -> Result<StatResult, ModuleError> {
handles::fstat(self, fd)
}
async fn fsync(&self, fd: f64) -> Result<(), ModuleError> {
handles::fsync(self, fd)
}
async fn ftruncate(&self, fd: f64, length: f64) -> Result<(), ModuleError> {
handles::ftruncate(self, fd, length)
}
async fn close(&self, fd: f64) -> Result<(), ModuleError> {
handles::close(self, fd)
}
async fn show_open_dialog(
&self,
options: OpenDialogOptions,
) -> Result<Vec<String>, ModuleError> {
dialogs::show_open(options)
}
async fn show_save_dialog(
&self,
options: SaveDialogOptions,
) -> Result<Option<String>, ModuleError> {
dialogs::show_save(options)
}
async fn reveal(&self, path: String) -> Result<(), ModuleError> {
dialogs::reveal(path)
}
async fn open_external(&self, path: String) -> Result<(), ModuleError> {
dialogs::open_external(path)
}
async fn watch(&self, path: String, recursive: bool) -> Result<f64, ModuleError> {
watching::watch(self, path, recursive)
}
fn unwatch(&self, id: f64) {
watching::unwatch(self, id);
}
fn read_file_sync(&self, path: String) -> Result<ArrayBuffer, ModuleError> {
files::read_file_sync(path)
}
fn read_text_file_sync(&self, path: String) -> Result<String, ModuleError> {
files::read_text_file_sync(path)
}
fn write_file_sync(
&self,
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
files::write_file_sync(path, data, flags, mode)
}
fn write_text_file_sync(
&self,
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
files::write_text_file_sync(path, data, flags, mode)
}
fn stat_sync(
&self,
path: String,
follow_symlinks: bool,
) -> Result<Option<StatResult>, ModuleError> {
files::stat_sync(path, follow_symlinks)
}
fn exists_sync(&self, path: String) -> Result<bool, ModuleError> {
files::exists_sync(path)
}
fn readdir_sync(&self, path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
dirs::readdir_sync(path, recursive)
}
fn mkdir_sync(&self, path: String, recursive: bool, mode: f64) -> Result<bool, ModuleError> {
dirs::mkdir_sync(path, recursive, mode)
}
fn rm_sync(&self, path: String, recursive: bool, force: bool) -> Result<bool, ModuleError> {
dirs::rm_sync(path, recursive, force)
}
fn rename_sync(&self, from: String, to: String) -> Result<bool, ModuleError> {
files::rename_sync(from, to)
}
fn copy_file_sync(&self, from: String, to: String) -> Result<bool, ModuleError> {
files::copy_file_sync(from, to)
}
fn get_paths(&self) -> Result<KnownPaths, ModuleError> {
paths::known_paths()
}
}
/// Called by the app's autolink shim, which keeps these modules linked in.
pub fn rn_gpui_register() {
rn_gpui_module::register(native_fs_module::<RNFsModule>());
}
+336
View File
@@ -0,0 +1,336 @@
//! Links and path checks: `access`, `realpath`, `readlink` and `symlink`.
//!
//! These are the calls whose exact meaning differs most between the platforms,
//! so each one is written against the platform's own primitive rather than
//! approximated from `std::fs` where that would change the answer.
use std::path::Path;
use std::path::PathBuf;
use rn_gpui_module::ModuleError;
use crate::errors;
use crate::module::access;
pub fn access(path: String, mode: f64) -> Result<(), ModuleError> {
let mode = access_mode(mode)?;
platform_access(&path, mode)
}
pub fn realpath(path: String) -> Result<String, ModuleError> {
let resolved = dunce::canonicalize(&path).map_err(|error| errors::from_io(error, &path))?;
// A `String` crosses the bridge as UTF-8, so a path that is not valid UTF-8
// has no faithful representation. Lossy-converting would hand JS a path it
// cannot open, which is worse than failing: `EINVAL` says the argument could
// not be represented rather than that the path does not exist.
resolved
.into_os_string()
.into_string()
.map_err(|unrepresentable| {
errors::invalid(
"EINVAL",
format!(
"EINVAL: invalid argument, path is not valid UTF-8, {}",
Path::new(&unrepresentable).display()
),
)
})
}
pub fn readlink(path: String) -> Result<String, ModuleError> {
let target = std::fs::read_link(&path).map_err(|error| errors::from_io(error, &path))?;
// As with `realpath`: the target is returned exactly as the file system
// stores it, and a target that is not UTF-8 cannot be a `String`.
target
.into_os_string()
.into_string()
.map_err(|unrepresentable| {
errors::invalid(
"EINVAL",
format!(
"EINVAL: invalid argument, link target is not valid UTF-8, {}",
Path::new(&unrepresentable).display()
),
)
})
}
pub fn symlink(target: String, path: String, kind: Option<&str>) -> Result<(), ModuleError> {
platform_symlink(&target, &path, kind)
}
/// Validate the bitmask Node numbers, returning it unchanged.
///
/// The caller gets Node's own bits back, not `access(2)`'s: `libc` 0.2 defines
/// `R_OK`, `W_OK` and `X_OK` only for Unix, so a Windows build that named them
/// failed with "cannot find value `R_OK` in crate `libc`". The translation to
/// `access(2)` happens in `platform_access`, which is already Unix-only, and the
/// Windows path reads the same Node bits directly.
///
/// `F_OK` is 0: the mode is not a flag set, and asking for nothing means "does
/// it exist".
fn access_mode(mode: f64) -> Result<i32, ModuleError> {
if !mode.is_finite() || mode < 0.0 || mode.fract() != 0.0 {
return Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, mode {mode}"),
));
}
let requested = mode as i32;
let known = access::F_OK | access::R_OK | access::W_OK | access::X_OK;
if requested & !known != 0 {
return Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, mode {requested}"),
));
}
Ok(requested)
}
/// `access(2)` is the only way to get Node's semantics — it asks the kernel about
/// *this* process's real uid and gid, including supplementary groups — so the
/// Unix implementation calls it rather than inspecting the mode bits, which
/// would give a different answer for a file the caller does not own.
#[cfg(unix)]
fn platform_access(path: &str, requested: i32) -> Result<(), ModuleError> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
// `access(2)` uses the same values Node does for these three, but the
// translation is spelled out so a platform whose constants differ still asks
// the right question. This is the only place the `libc` constants are named.
let mut mask = libc::F_OK;
if requested & access::R_OK != 0 {
mask |= libc::R_OK;
}
if requested & access::W_OK != 0 {
mask |= libc::W_OK;
}
if requested & access::X_OK != 0 {
mask |= libc::X_OK;
}
// A path with an interior NUL cannot be passed to the C API at all; that is
// an invalid argument rather than a file-system failure.
let name = CString::new(std::ffi::OsStr::new(path).as_bytes()).map_err(|_| {
errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, path contains a null byte, {path}"),
)
})?;
// `access` reports failure by return value and sets `errno`, so the error is
// built from `last_os_error` and then mapped like any other.
// SAFETY: `name` is a NUL-terminated string that outlives the call.
let result = unsafe { libc::access(name.as_ptr(), mask) };
if result == 0 {
Ok(())
} else {
Err(errors::from_io(std::io::Error::last_os_error(), path))
}
}
/// Windows has no `access(2)`, so the checks are approximated from what the
/// platform does offer: `F_OK` and `R_OK` are a `metadata()` call (the read bit
/// is not expressible, and a file the process can stat is one it can normally
/// read), `W_OK` tries to open the file for writing, and `X_OK` uses the
/// executable extensions the shell recognises, because Windows has no execute
/// bit to test.
#[cfg(not(unix))]
fn platform_access(path: &str, requested: i32) -> Result<(), ModuleError> {
// `F_OK` asks only that the path resolves; `metadata` follows links, which
// matches `access`'s "does the name lead to something".
std::fs::metadata(path).map_err(|error| errors::from_io(error, path))?;
if requested & access::W_OK != 0 {
std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|error| errors::from_io(error, path))?;
}
if requested & access::X_OK != 0 && !is_executable(Path::new(path)) {
return Err(errors::invalid(
"EACCES",
format!("EACCES: permission denied, {path}"),
));
}
Ok(())
}
/// The extensions `PATHEXT` names, which is how Windows decides a file is a
/// program. A directory is executable for `access`'s purposes because the caller
/// can `chdir` into it.
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
if path.is_dir() {
return true;
}
let extensions = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
return false;
};
let extension = format!(".{}", extension.to_ascii_uppercase());
extensions
.split(';')
.any(|candidate| candidate.eq_ignore_ascii_case(&extension))
}
/// Unix ignores the link kind: `symlink(2)` does not have one, and the target is
/// resolved lazily on every traversal, so a link to a directory works without
/// being declared as one.
#[cfg(unix)]
fn platform_symlink(target: &str, path: &str, kind: Option<&str>) -> Result<(), ModuleError> {
let _ = kind;
std::os::unix::fs::symlink(target, path).map_err(|error| errors::from_io(error, path))
}
/// Windows needs a different call for a file link and a directory link, and it
/// needs the developer-mode privilege (or SeCreateSymbolicLinkPrivilege) for
/// either. The design says to report the OS error rather than fall back to
/// copying, so a caller without the privilege sees the real reason.
///
/// The kind decides which call, following Node's rules. It cannot be inferred
/// from the target alone: a link may name a path that does not exist yet, and
/// `metadata` would fail on exactly the case that matters. `'junction'` gets
/// `symlink_dir` — a directory symlink rather than a true NTFS junction, which
/// needs a different API and an absolute target; both are traversable, which is
/// what a caller wants.
#[cfg(not(unix))]
fn platform_symlink(target: &str, path: &str, kind: Option<&str>) -> Result<(), ModuleError> {
let directory = match kind {
Some("dir") | Some("junction") => true,
Some("file") => false,
_ => {
// Node's rule when no type is given: resolve the target relative to
// the link's parent — the shell does the same — and take the answer
// from what is there.
let resolved = match Path::new(path).parent() {
Some(parent) => parent.join(target),
None => PathBuf::from(target),
};
resolved.is_dir()
}
};
let result = if directory {
std::os::windows::fs::symlink_dir(target, path)
} else {
std::os::windows::fs::symlink_file(target, path)
};
result.map_err(|error| errors::from_io(error, path))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tempdir() -> TempDir {
TempDir::new().expect("a temporary directory")
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[test]
fn access_finds_a_present_path_and_reports_a_missing_one() {
let dir = tempdir();
let present = dir.path().join("here.txt");
std::fs::write(&present, b"x").expect("write the fixture file");
let absent = dir.path().join("gone.txt");
access(text(&present), f64::from(access::F_OK)).expect("a present path exists");
access(text(&present), f64::from(access::R_OK | access::W_OK))
.expect("a file we just wrote is readable and writable");
let error = access(text(&absent), f64::from(access::F_OK)).expect_err("a missing path");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn access_rejects_a_mode_node_does_not_define() {
let dir = tempdir();
let error = access(text(dir.path()), 8.0).expect_err("an unknown bit");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
}
/// `dunce::canonicalize` resolves the whole chain, so the result is the real
/// file rather than the link. Windows is skipped: creating a symlink there
/// needs developer mode, which a CI runner may not have.
#[cfg(unix)]
#[test]
fn realpath_resolves_a_symlink() {
let dir = tempdir();
let file = dir.path().join("real.txt");
std::fs::write(&file, b"x").expect("write the fixture file");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&file, &link).expect("create the link");
assert_eq!(
realpath(text(&link)).expect("realpath"),
realpath(text(&file)).expect("realpath")
);
assert_eq!(
realpath(text(&file)).expect("realpath"),
dunce::canonicalize(&file)
.expect("canonical")
.to_string_lossy()
);
}
#[test]
fn realpath_of_a_missing_path_is_enoent() {
let dir = tempdir();
let error = realpath(text(&dir.path().join("gone"))).expect_err("missing");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
/// The link is read, not followed: the target comes back as it was stored,
/// relative or absolute.
#[cfg(unix)]
#[test]
fn readlink_returns_the_stored_target() {
let dir = tempdir();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink("some/relative/target", &link).expect("create the link");
assert_eq!(
readlink(text(&link)).expect("readlink"),
"some/relative/target"
);
let file = dir.path().join("real.txt");
std::fs::write(&file, b"x").expect("write the fixture file");
let error = readlink(text(&file)).expect_err("a plain file is not a link");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
}
/// Windows is skipped because creating a symlink there needs developer mode;
/// the OS error would be the correct result, not a test failure.
#[cfg(unix)]
#[test]
fn symlink_round_trips_through_readlink() {
let dir = tempdir();
let target = dir.path().join("target.txt");
std::fs::write(&target, b"x").expect("write the fixture file");
let link = dir.path().join("link.txt");
symlink(text(&target), text(&link), None).expect("symlink");
assert_eq!(readlink(text(&link)).expect("readlink"), text(&target));
assert_eq!(std::fs::read(&link).expect("through the link"), b"x");
}
#[test]
fn symlinking_onto_an_existing_path_is_eexist() {
let dir = tempdir();
let one = dir.path().join("one.txt");
let two = dir.path().join("two.txt");
std::fs::write(&one, b"1").expect("write");
std::fs::write(&two, b"2").expect("write");
let error = symlink(text(&one), text(&two), None).expect_err("the name is taken");
#[cfg(unix)]
assert_eq!(error.code.as_deref(), Some("EEXIST"));
// Windows reports the same collision, but through a Win32 code whose
// kind is "already exists"; only the presence of an error is portable.
#[cfg(not(unix))]
assert!(error.code.is_some());
}
}
@@ -0,0 +1,187 @@
//! Shared state and helpers for the file system module.
//!
//! The trait's methods are split across sibling files by concern — `files`,
//! `dirs`, `handles`, `paths`, `dialogs`, `watching` — because they are large and
//! largely independent. Rust allows the trait to be implemented in several files
//! of one crate, so each file owns its own `impl` block.
//!
//! Everything they share is here: the open file table, the flag translation, and
//! the conversions the bridge's `f64` arguments need.
use std::fs::File;
use std::sync::atomic::AtomicU32;
use std::sync::Mutex;
use rn_gpui_module::{ModuleError, ModuleThread};
use crate::NativeFsEvents;
use crate::errors;
/// Flags, as `node:fs` numbers them: Node's table, not the platform's, so these
/// are translated rather than passed through to `OpenOptions`.
pub mod flags {
pub const RDONLY: i32 = 0;
pub const WRONLY: i32 = 1;
pub const RDWR: i32 = 2;
pub const CREAT: i32 = 64;
pub const EXCL: i32 = 128;
pub const TRUNC: i32 = 512;
pub const APPEND: i32 = 1024;
}
/// Access modes for `access`, matching `node:fs.constants`.
pub mod access {
pub const F_OK: i32 = 0;
pub const X_OK: i32 = 1;
pub const W_OK: i32 = 2;
pub const R_OK: i32 = 4;
}
/// The kind numbers the JS layer turns into `Stats` predicates.
pub mod kind {
pub const FILE: f64 = 0.0;
pub const DIRECTORY: f64 = 1.0;
pub const SYMLINK: f64 = 2.0;
pub const OTHER: f64 = 3.0;
}
pub struct RNFsModule {
pub events: NativeFsEvents,
/// Open files, keyed by the number JS holds. The table belongs to the
/// instance, so a reload closes every descriptor when it drops.
///
/// Each entry records whether the descriptor was opened writable. The OS
/// reports a write to a read-only handle as `EBADF` on Unix and `EACCES` on
/// Windows, and Node reports `EBADF` on both, so the answer is taken from what
/// the handle was opened with rather than from the errno.
pub files: Mutex<std::collections::HashMap<u32, (File, bool)>>,
/// Never reused, so a stale `fd` from JS fails instead of closing an
/// unrelated file.
pub next_fd: AtomicU32,
/// Live watchers, keyed the same way.
pub watchers: Mutex<std::collections::HashMap<u32, crate::watching::Watcher>>,
pub next_watcher: AtomicU32,
}
impl RNFsModule {
pub fn create(events: NativeFsEvents) -> Self {
Self {
events,
files: Mutex::new(std::collections::HashMap::new()),
next_fd: AtomicU32::new(1),
watchers: Mutex::new(std::collections::HashMap::new()),
next_watcher: AtomicU32::new(1),
}
}
pub fn take_fd(&self) -> u32 {
self.next_fd
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
pub fn take_watcher_id(&self) -> u32 {
self.next_watcher
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// A clone of an open file, sharing its offset — which is what a descriptor
/// is. Holding the lock only for the lookup keeps a slow write from blocking
/// every other call on this thread.
pub fn file(&self, fd: f64) -> Result<File, ModuleError> {
self.file_and_writability(fd).map(|(file, _)| file)
}
/// The handle and whether it can be written to.
pub fn file_and_writability(&self, fd: f64) -> Result<(File, bool), ModuleError> {
let id = descriptor(fd)?;
let files = self.files.lock().unwrap_or_else(|e| e.into_inner());
let (file, writable) = files.get(&id).ok_or_else(|| bad_fd(id))?;
let cloned = file
.try_clone()
.map_err(|error| errors::from_io(error, &id.to_string()))?;
Ok((cloned, *writable))
}
}
/// The error Node raises for a descriptor it does not know.
pub fn bad_fd(id: u32) -> ModuleError {
errors::invalid("EBADF", format!("EBADF: bad file descriptor, {id}"))
}
/// A descriptor from the bridge. Anything but a non-negative integer is a
/// programmer error, so it reports as a bad descriptor rather than a file error.
pub fn descriptor(value: f64) -> Result<u32, ModuleError> {
if value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= u32::MAX as f64 {
Ok(value as u32)
} else {
Err(bad_fd(0))
}
}
/// A byte length from the bridge.
pub fn length(value: f64) -> Result<usize, ModuleError> {
if value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= u32::MAX as f64 {
Ok(value as usize)
} else {
Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, length {value}"),
))
}
}
/// A permission bitmask, clamped to the bits `std::fs` accepts. Zero means the
/// caller passed nothing, which Node treats as `0o666`.
pub fn mode(value: f64) -> u32 {
if value.is_finite() && value > 0.0 {
value as u32 & 0o7777
} else {
0o666
}
}
/// `node:fs` numeric flags as `OpenOptions`.
pub fn open_options(flags_value: f64, mode: u32) -> std::fs::OpenOptions {
let flags_value = flags_value as i32;
let mut options = std::fs::OpenOptions::new();
// Bit tests, not `matches!`: `matches!(value, A | B)` is the or-pattern
// `value == A || value == B`, which for Node's flags asks whether the whole
// mask equals 0 or 2. `'w'` is `WRONLY | CREAT | TRUNC` (577), so that test
// was false and every write opened read-only and failed `EINVAL`.
// `RDONLY` is zero, so "read" is the absence of `WRONLY` rather than a bit.
options
.read(flags_value & flags::WRONLY == 0)
.write(flags_value & (flags::WRONLY | flags::RDWR) != 0)
.create(flags_value & flags::CREAT != 0)
.create_new(flags_value & flags::EXCL != 0)
.truncate(flags_value & flags::TRUNC != 0)
.append(flags_value & flags::APPEND != 0);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(mode);
}
#[cfg(not(unix))]
let _ = mode;
options
}
/// Milliseconds since the epoch, as the bridge sends them.
pub fn system_time(ms: f64) -> std::time::SystemTime {
if ms.is_finite() && ms >= 0.0 {
std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64)
} else {
std::time::UNIX_EPOCH
}
}
/// A `SystemTime` as milliseconds, which is what `StatResult` carries.
pub fn millis(time: std::time::SystemTime) -> f64 {
time.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as f64)
.unwrap_or(0.0)
}
// The spec says `THREAD = Own`; this keeps the module compiled against that.
const _: ModuleThread = ModuleThread::Own;
+231
View File
@@ -0,0 +1,231 @@
//! The well-known folders, as one `KnownPaths` object.
//!
//! `dirs` answers for the folders every platform has. The per-application three
//! — `appData`, `appCache`, `appLogs` — are this module's own convention, because
//! the host does not yet tell modules which app they belong to; see
//! `app_id` below. All three are created on the first read, as the design asks,
//! so a caller can write a log file without a `mkdir` first.
use std::path::{Path, PathBuf};
use rn_gpui_module::ModuleError;
use crate::KnownPaths;
/// The public folders plus the three per-application ones, created if needed.
///
/// Nothing here fails: a folder that does not exist is reported as the temp
/// directory instead. The design treats these as conveniences, and a caller
/// asking where to put a log file is better served by a working stand-in than by
/// a thrown error it cannot recover from — the alternative is a crash on a
/// system where, say, `Desktop` was never created.
pub fn known_paths() -> Result<KnownPaths, ModuleError> {
let temp = std::env::temp_dir();
let id = app_id();
let app_data = dirs::data_dir().map(|dir| dir.join(&id));
let app_cache = dirs::cache_dir().map(|dir| dir.join(&id));
let app_logs = logs_dir(&id);
Ok(KnownPaths {
home: fallback(dirs::home_dir(), &temp),
documents: fallback(dirs::document_dir(), &temp),
downloads: fallback(dirs::download_dir(), &temp),
desktop: fallback(dirs::desktop_dir(), &temp),
temp: path_string(&temp),
app_data: ensure_directory(app_data, &temp),
app_cache: ensure_directory(app_cache, &temp),
app_logs: ensure_directory(app_logs, &temp),
})
}
/// A folder as a string, with the temp directory standing in when the platform
/// cannot name it.
fn fallback(found: Option<PathBuf>, temp: &Path) -> String {
path_string(found.as_deref().unwrap_or(temp))
}
/// A path as `KnownPaths` carries it. A non-UTF-8 path cannot be a JS string, so
/// the lossy form is used rather than failing: it is still a path the platform
/// will resolve, and the folder is only a suggestion to the caller.
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// Creates a directory that the returned path promises exists, falling back to
/// the temp directory if it cannot be created.
///
/// A failure here means the platform gave no base directory at all, or that it
/// exists but is not writable; temp is the one directory a process can normally
/// count on, so the caller still gets somewhere to write.
fn ensure_directory(found: Option<PathBuf>, temp: &Path) -> String {
let Some(directory) = found else {
return path_string(temp);
};
match std::fs::create_dir_all(&directory) {
Ok(()) => path_string(&directory),
Err(_) => path_string(temp),
}
}
/// Where `appLogs` lives, which is the one app folder whose convention differs
/// by platform: macOS keeps logs under `~/Library/Logs`, the others under the
/// data directory. Both get the app id as their last component so two apps do
/// not share a log folder.
fn logs_dir(id: &str) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
dirs::home_dir().map(|home| home.join("Library").join("Logs").join(id))
}
#[cfg(not(target_os = "macos"))]
{
dirs::data_dir().map(|dir| dir.join(id).join("logs"))
}
}
/// The id the three per-app folders are namespaced by.
///
/// This is a **placeholder**. The design wants these paths to be per-app and
/// says the host already knows the id (`DesktopAppConfig::app_id`), but no host
/// function exposes it to modules yet: `host_info().app_id` does not exist. Until
/// that API lands, `RN_GPUI_APP_ID` is honoured when the app sets it, and the
/// executable's file stem is the fallback — stable for a given binary, which is
/// enough to keep the folders apart and across runs.
///
/// When `host_info().app_id` is added, this function should read it and the two
/// fallbacks should go, because a renamed binary would otherwise move the app's
/// data and cache out from under the user.
fn app_id() -> String {
let from_environment = std::env::var("RN_GPUI_APP_ID").ok();
let from_executable = std::env::current_exe().ok().and_then(|exe| {
exe.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
});
pick_app_id(from_environment.as_deref(), from_executable.as_deref())
}
/// The selection half of `app_id`, split out so the precedence and the fallback
/// can be tested without touching the environment, which other tests share.
fn pick_app_id(from_environment: Option<&str>, from_executable: Option<&str>) -> String {
[from_environment, from_executable]
.into_iter()
.flatten()
.map(sanitise)
.find(|id| !id.is_empty())
// A process whose executable has no usable name still needs folders, and
// the name only has to be stable, not accurate.
.unwrap_or_else(|| "rn-gpui-app".to_string())
}
/// Strips the characters that cannot appear in a directory name on any of the
/// three platforms, so an id from the environment cannot escape its parent
/// directory or fail `create_dir_all` on Windows.
///
/// Leading and trailing dots and dashes go too: an id that was only separators
/// sanitises to nothing rather than to a directory named `-`, and one that began
/// with `..` cannot end up looking like a parent reference.
fn sanitise(id: &str) -> String {
id.trim()
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '-',
other => other,
})
.collect::<String>()
.trim_matches(|character| character == '.' || character == '-')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_known_path_is_a_non_empty_string() {
let paths = known_paths().expect("known paths");
for (name, value) in [
("home", &paths.home),
("documents", &paths.documents),
("downloads", &paths.downloads),
("desktop", &paths.desktop),
("temp", &paths.temp),
("appData", &paths.app_data),
("appCache", &paths.app_cache),
("appLogs", &paths.app_logs),
] {
assert!(!value.is_empty(), "{name} was empty");
}
}
#[test]
fn the_app_folders_exist_after_the_first_read() {
let paths = known_paths().expect("known paths");
for (name, value) in [
("appData", &paths.app_data),
("appCache", &paths.app_cache),
("appLogs", &paths.app_logs),
] {
assert!(
Path::new(value).is_dir(),
"{name} was not created at {value}"
);
}
}
#[test]
fn the_app_folders_are_distinct() {
let paths = known_paths().expect("known paths");
let app_data = Path::new(&paths.app_data);
let app_cache = Path::new(&paths.app_cache);
let app_logs = Path::new(&paths.app_logs);
assert_ne!(app_data, app_cache);
assert_ne!(app_data, app_logs);
assert_ne!(app_cache, app_logs);
}
#[test]
fn a_second_read_returns_the_same_folders() {
// Creating a directory that already exists has to keep succeeding, since
// the JS layer may call `getPaths()` more than once per run.
let first = known_paths().expect("known paths");
let second = known_paths().expect("known paths");
assert_eq!(first.app_data, second.app_data);
assert_eq!(first.app_cache, second.app_cache);
assert_eq!(first.app_logs, second.app_logs);
}
#[test]
fn an_id_from_the_environment_names_the_app_folders() {
// Only the pure part is asserted: mutating the environment would race
// with the other tests in this module, which read it concurrently.
assert_eq!(sanitise("my-app"), "my-app");
assert_eq!(sanitise(" ../escape "), "escape");
assert_eq!(sanitise("a/b\\c"), "a-b-c");
assert_eq!(sanitise(" "), "");
}
#[test]
fn the_environment_id_wins_over_the_executable_name() {
assert_eq!(pick_app_id(Some("from-env"), Some("from-exe")), "from-env");
}
#[test]
fn an_unusable_environment_id_falls_through_to_the_executable_name() {
assert_eq!(pick_app_id(Some(" "), Some("from-exe")), "from-exe");
// A separator-only id would escape its parent, which `sanitise` turns
// into a name rather than a path.
assert_eq!(pick_app_id(Some("/"), Some("from-exe")), "from-exe");
}
#[test]
fn no_id_at_all_still_yields_a_stable_name() {
assert_eq!(pick_app_id(None, None), "rn-gpui-app");
assert_eq!(pick_app_id(Some(""), Some("")), "rn-gpui-app");
}
#[test]
fn a_hostile_executable_name_cannot_escape_the_base_directory() {
assert_eq!(pick_app_id(None, Some("../../etc/passwd")), "etc-passwd");
}
}
@@ -0,0 +1,392 @@
//! The watcher behind `watch` and `unwatch`.
//!
//! `notify` gives each watcher a thread of its own and calls the handler on it.
//! That handler cannot borrow the module — it may outlive any single call and
//! runs on a thread the module does not own — so it is handed a clone of the
//! event emitter and an `Arc` of the debounce table instead. The module only
//! stores the `RecommendedWatcher`; dropping that value stops the OS watch, and
//! with it the thread, which is how a JS reload closes every watcher at once.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use notify::event::EventKind;
use notify::Watcher as _;
use notify::{RecommendedWatcher, RecursiveMode};
use rn_gpui_module::ModuleError;
use crate::{errors, RNFsModule, WatchEvent};
/// How long a path stays quiet after being reported.
///
/// An editor saving a file usually writes it several times in a burst (write,
/// rename over the original, a truncated open), and a recursive watch on a build
/// directory can see dozens of events for one logical change. Fifty milliseconds
/// collapses a burst into the single event a caller can act on without making a
/// genuinely repeated edit invisible.
const DEBOUNCE: Duration = Duration::from_millis(50);
/// An installed watch, kept alive by the module's table.
///
/// The field is never read: it exists so that removing the entry drops it, which
/// is what stops notify's thread and the platform watch.
pub struct Watcher {
_watcher: RecommendedWatcher,
}
/// What was last reported for a path, and when.
#[derive(Clone, Copy)]
struct LastReport {
at: Instant,
kind: EventKindTag,
}
/// Which of the three stable names an event carries.
///
/// A copy of the mapping `kind_name` produces, so the debounce can compare two
/// events without allocating the string it will not send.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum EventKindTag {
Create,
Modify,
Remove,
Other,
}
/// Per-path timestamps of the last event that was reported.
///
/// Shared with the handler through an `Arc` because the handler owns its state
/// for as long as the watcher lives, which is longer than any borrow of the
/// module. Paths that fall out of the window are pruned on every report, so a
/// long-running recursive watch cannot grow this without bound.
///
/// The rule, in one sentence: **a burst is collapsed per path and per kind, and a
/// removal is never collapsed away.** Keeping only the first event of a burst
/// lost the end of it — a create followed by a remove reported only `create`, so
/// a caller was told about a file that no longer existed. Two adjustments fix
/// that without giving up the collapsing that makes an editor's write burst
/// readable:
///
/// - a path's window is per kind, so a `remove` after a `create` is a different
/// event rather than a suppressed one;
/// - a removal always reports, whatever the window, because it is the last state
/// a path can reach and swallowing it leaves the caller's view permanently
/// wrong. The cost is a possible duplicate removal for one logical deletion,
/// which a watcher callback can tolerate; a missing one it cannot.
#[derive(Default)]
struct Debounce {
last: Mutex<HashMap<(PathBuf, EventKindTag), Instant>>,
}
impl Debounce {
/// Whether this event should be delivered now.
///
/// Takes `now` rather than reading the clock so the window can be tested
/// without sleeping.
fn should_report_at(&self, path: &Path, kind: EventKindTag, now: Instant) -> bool {
let mut last = self.last.lock().unwrap_or_else(|error| error.into_inner());
let key = (path.to_path_buf(), kind);
let report = match last.get(&key).copied() {
Some(previous) => {
kind == EventKindTag::Remove || now.saturating_duration_since(previous) >= DEBOUNCE
}
None => true,
};
if report {
last.retain(|_, previous| now.saturating_duration_since(*previous) < DEBOUNCE);
last.insert(key, now);
}
report
}
fn should_report(&self, path: &Path, kind: EventKindTag) -> bool {
self.should_report_at(path, kind, Instant::now())
}
}
/// The stable string the JS layer matches on.
///
/// Everything that is not a create, modify or remove — access events, and the
/// catch-all notify reports when a backend cannot be more precise — becomes
/// `other` rather than leaking a platform-specific spelling.
fn kind_name(kind: &EventKind) -> &'static str {
match kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "remove",
_ => "other",
}
}
/// The tag `kind_name` corresponds to, for the debounce to compare by.
fn kind_tag(kind: &EventKind) -> EventKindTag {
match kind {
EventKind::Create(_) => EventKindTag::Create,
EventKind::Modify(_) => EventKindTag::Modify,
EventKind::Remove(_) => EventKindTag::Remove,
_ => EventKindTag::Other,
}
}
/// An event path as the caller should see it.
///
/// Node reports the path relative to the watch root, and a name alone for a
/// single-file watch. The platform reports an absolute path, and on macOS a
/// *canonical* one: FSEvents resolves `/var` to `/private/var`, so a plain string
/// prefix check against the path the caller gave strips nothing and the callback
/// receives an absolute path. Both roots are therefore tried, and the given one
/// last so a caller watching through a symlink still gets a relative name.
fn relative_event_path(path: &Path, roots: &[PathBuf], watching_file: bool) -> String {
if watching_file {
return path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
}
for root in roots {
if let Ok(rest) = path.strip_prefix(root) {
// Normalised to `/`: Node reports relative paths with forward
// slashes on every platform, and a Windows caller would otherwise get
// `nested\file.txt` where Node gives `nested/file.txt`.
return rest.to_string_lossy().replace('\\', "/");
}
}
path.to_string_lossy().into_owned()
}
/// A notify failure as the error JS expects.
///
/// Most failures wrap an `io::Error` that already maps through `errors::from_io`;
/// the ones that do not are described well enough by notify to be reported with
/// the closest Node code.
fn watcher_error(error: notify::Error, path: &str) -> ModuleError {
use notify::ErrorKind;
let io = match error.kind {
ErrorKind::Io(error) => error,
ErrorKind::PathNotFound => std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
ErrorKind::MaxFilesWatch => {
std::io::Error::new(std::io::ErrorKind::Other, "OS file watch limit reached")
}
other => std::io::Error::new(std::io::ErrorKind::Other, format!("{other:?}")),
};
errors::from_io(io, path)
}
/// Starts watching `path`, returning the id events carry.
pub fn watch(module: &RNFsModule, path: String, recursive: bool) -> Result<f64, ModuleError> {
// Node fails a watch on a missing path immediately, and so must this: the
// platform watchers otherwise accept the path and then go silent, which is
// indistinguishable at the call site from a directory nothing ever touches.
let target = PathBuf::from(&path);
if !target.exists() {
return Err(errors::from_io(
std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
&path,
));
}
let id = module.take_watcher_id();
let debounce = Arc::new(Debounce::default());
let events = module.events.clone();
// The canonical root is what macOS actually reports inside, and the given
// root is what the caller passed; keeping both means the relative name
// survives either spelling.
let watching_file = target.is_file();
let roots: Vec<PathBuf> = {
let mut roots = Vec::new();
if let Ok(canonical) = target.canonicalize() {
roots.push(canonical);
}
roots.push(target.clone());
roots
};
let handler = move |result: notify::Result<notify::Event>| {
// A backend error has nowhere to go: it is not tied to a call, and
// reporting it through the event stream would make it look like a file
// change. notify's thread keeps running, so later events still arrive.
let Ok(event) = result else { return };
let kind = kind_name(&event.kind);
let tag = kind_tag(&event.kind);
// One platform event can name several paths — a directory move, for
// instance — and a caller wants one delivery per path.
for path in &event.paths {
if !debounce.should_report(path, tag) {
continue;
}
events.on_watch_event(WatchEvent {
id: f64::from(id),
kind: kind.to_string(),
path: relative_event_path(path, &roots, watching_file),
});
}
};
let mut watcher =
notify::recommended_watcher(handler).map_err(|error| watcher_error(error, &path))?;
let mode = if recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
watcher
.watch(&target, mode)
.map_err(|error| watcher_error(error, &path))?;
module
.watchers
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(id, Watcher { _watcher: watcher });
Ok(f64::from(id))
}
/// Stops a watcher. An id that is already gone is not a failure: JS may close a
/// watcher twice, or close one whose module has already dropped it.
pub fn unwatch(module: &RNFsModule, id: f64) {
let Ok(id) = crate::module::descriptor(id) else {
return;
};
// Removing the entry drops the watcher, which stops notify's thread.
module
.watchers
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(&id);
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, ModifyKind, RemoveKind};
#[test]
fn event_kinds_map_to_stable_names() {
assert_eq!(kind_name(&EventKind::Create(CreateKind::File)), "create");
assert_eq!(kind_name(&EventKind::Create(CreateKind::Any)), "create");
assert_eq!(kind_name(&EventKind::Modify(ModifyKind::Any)), "modify");
assert_eq!(kind_name(&EventKind::Remove(RemoveKind::Any)), "remove");
assert_eq!(kind_name(&EventKind::Any), "other");
}
#[test]
fn the_first_event_for_a_path_is_reported() {
let debounce = Debounce::default();
let now = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, now));
}
#[test]
fn a_burst_is_collapsed_into_one_event() {
let debounce = Debounce::default();
let start = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, start));
assert!(!debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(10)
));
assert!(!debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(49)
));
assert!(debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(50)
));
}
#[test]
fn a_remove_inside_a_burst_is_not_swallowed() {
let debounce = Debounce::default();
let start = Instant::now();
let path = Path::new("/a");
// A create and the removal that follows it are two states of the path,
// not one event seen twice: reporting only the create tells a caller
// about a file that is already gone.
assert!(debounce.should_report_at(path, EventKindTag::Create, start));
assert!(debounce.should_report_at(
path,
EventKindTag::Remove,
start + Duration::from_millis(5)
));
// A second removal for the same deletion may arrive; a missing one may
// not, so this is deliberately the lenient side of the trade.
assert!(debounce.should_report_at(
path,
EventKindTag::Remove,
start + Duration::from_millis(10)
));
}
#[test]
fn a_modify_burst_still_collapses() {
let debounce = Debounce::default();
let start = Instant::now();
let path = Path::new("/a");
assert!(debounce.should_report_at(path, EventKindTag::Modify, start));
// Two writes a millisecond apart are one logical change.
assert!(!debounce.should_report_at(
path,
EventKindTag::Modify,
start + Duration::from_millis(1)
));
}
#[test]
fn a_canonical_root_still_yields_a_relative_name() {
// macOS reports canonical paths, so a watch on `/var/...` sees
// `/private/var/...`. Both roots are tried, and the caller gets a name
// relative to the one they asked about either way.
let canonical = PathBuf::from("/private/var/folders/x/tmp");
let given = PathBuf::from("/var/folders/x/tmp");
let roots = vec![canonical.clone(), given];
let event = canonical.join("nested").join("file.txt");
assert_eq!(
relative_event_path(&event, &roots, false),
"nested/file.txt"
);
// A path outside every root is passed through rather than mangled.
assert_eq!(
relative_event_path(Path::new("/elsewhere/file.txt"), &roots, false),
"/elsewhere/file.txt"
);
// Watching a single file reports its name, which is what Node does.
assert_eq!(relative_event_path(&event, &roots, true), "file.txt");
}
#[test]
fn debouncing_is_per_path() {
let debounce = Debounce::default();
let start = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, start));
assert!(debounce.should_report_at(
Path::new("/b"),
EventKindTag::Modify,
start + Duration::from_millis(1)
));
assert!(!debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(2)
));
}
#[test]
fn paths_outside_the_window_are_forgotten() {
let debounce = Debounce::default();
let start = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, start));
assert!(debounce.should_report_at(
Path::new("/b"),
EventKindTag::Modify,
start + Duration::from_millis(60)
));
let last = debounce.last.lock().unwrap();
assert_eq!(last.len(), 1, "the stale path should have been pruned");
assert!(last.contains_key(&(PathBuf::from("/b"), EventKindTag::Modify)));
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@react-native-gpui/fs",
"version": "0.1.0",
"main": "src/index.ts",
"files": [
"src",
"desktop",
"react-native.config.js",
"README.md"
],
"codegenConfig": {
"name": "RNFsModuleSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"peerDependencies": {
"react-native": ">=0.76"
}
}
@@ -0,0 +1,9 @@
module.exports = {
dependency: {
platforms: {
desktop: {
crate: 'rn-gpui-fs',
},
},
},
};
+162
View File
@@ -0,0 +1,162 @@
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
/**
* How a path entry is classified: 0 file, 1 directory, 2 symlink, 3 other.
*
* A plain number rather than a string or enum so it crosses the bridge without a
* lookup table and survives being read back out of a plain object in JS.
*/
export type FileKind = number;
export type StatResult = {
size: number;
mode: number;
kind: FileKind;
atimeMs: number;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
dev: number;
ino: number;
nlink: number;
uid: number;
gid: number;
};
export type DirEntry = {
name: string;
kind: FileKind;
parentPath: string;
};
export type DialogFilter = {
name: string;
extensions: Array<string>;
};
export type OpenDialogOptions = {
title?: string;
defaultPath?: string;
multiple: boolean;
directory: boolean;
filters: Array<DialogFilter>;
};
export type SaveDialogOptions = {
title?: string;
defaultPath?: string;
defaultName?: string;
filters: Array<DialogFilter>;
};
export type KnownPaths = {
home: string;
documents: string;
downloads: string;
desktop: string;
temp: string;
appData: string;
appCache: string;
appLogs: string;
};
export type WatchEvent = {
id: number;
kind: string;
path: string;
};
/**
* The file system module: one instance, on its own thread, so a slow file never
* blocks JS or the UI.
*
* A method that returns a value runs on the JS thread; `void` and `Promise`
* methods run on the module's thread. The writers below therefore return
* `boolean` — a `void` method would be moved off the JS thread, which is wrong
* for a `*Sync` call. They always return true and throw on failure.
*/
export interface Spec extends TurboModule {
// Text is separate from bytes so a `utf8` read or write needs no buffer.
readFile(path: string): Promise<ArrayBuffer>;
readTextFile(path: string): Promise<string>;
writeFile(
path: string,
data: ArrayBuffer,
flags: number,
mode: number,
): Promise<void>;
writeTextFile(
path: string,
data: string,
flags: number,
mode: number,
): Promise<void>;
readdir(path: string, recursive: boolean): Promise<Array<DirEntry>>;
// `followSymlinks` false is `lstat`.
stat(path: string, followSymlinks: boolean): Promise<StatResult>;
mkdir(path: string, recursive: boolean, mode: number): Promise<void>;
rm(path: string, recursive: boolean, force: boolean): Promise<void>;
rename(from: string, to: string): Promise<void>;
copy(from: string, to: string, recursive: boolean): Promise<void>;
access(path: string, mode: number): Promise<void>;
realpath(path: string): Promise<string>;
readlink(path: string): Promise<string>;
// `type` is Node's, and only Windows acts on it: `'dir'` and `'junction'`
// create a directory link, `null` decides from the resolved target, and a
// plain `'file'` is a file link. Unix ignores it, because the syscall does.
// Without it a Windows directory link was created as a file link and could
// not be traversed.
symlink(target: string, path: string, type: string | null): Promise<void>;
truncate(path: string, length: number): Promise<void>;
utimes(path: string, atimeMs: number, mtimeMs: number): Promise<void>;
chmod(path: string, mode: number): Promise<void>;
mkdtemp(prefix: string): Promise<string>;
// File handles. `position` -1 means "at the current offset".
open(path: string, flags: number, mode: number): Promise<number>;
read(fd: number, length: number, position: number): Promise<ArrayBuffer>;
write(fd: number, data: ArrayBuffer, position: number): Promise<number>;
fstat(fd: number): Promise<StatResult>;
fsync(fd: number): Promise<void>;
ftruncate(fd: number, length: number): Promise<void>;
close(fd: number): Promise<void>;
showOpenDialog(options: OpenDialogOptions): Promise<Array<string>>;
showSaveDialog(options: SaveDialogOptions): Promise<string | null>;
reveal(path: string): Promise<void>;
openExternal(path: string): Promise<void>;
// Returns the watcher id; events arrive through `onWatchEvent`.
watch(path: string, recursive: boolean): Promise<number>;
unwatch(id: number): void;
readFileSync(path: string): ArrayBuffer;
readTextFileSync(path: string): string;
writeFileSync(
path: string,
data: ArrayBuffer,
flags: number,
mode: number,
): boolean;
writeTextFileSync(
path: string,
data: string,
flags: number,
mode: number,
): boolean;
statSync(path: string, followSymlinks: boolean): StatResult | null;
existsSync(path: string): boolean;
readdirSync(path: string, recursive: boolean): Array<DirEntry>;
mkdirSync(path: string, recursive: boolean, mode: number): boolean;
rmSync(path: string, recursive: boolean, force: boolean): boolean;
renameSync(from: string, to: string): boolean;
copyFileSync(from: string, to: string): boolean;
getPaths(): KnownPaths;
readonly onWatchEvent: CodegenTypes.EventEmitter<WatchEvent>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('RNFsModule');
+43
View File
@@ -0,0 +1,43 @@
/**
* `fs.constants`, with Node's numbers.
*
* The `O_*` values are Node's table rather than the platform's `open(2)` flags,
* because that is what the Rust side decodes: it translates them into
* `OpenOptions` instead of passing them through. The numbers here must stay in
* step with `modules/fs/desktop/src/module.rs`, which owns the other half of the
* contract.
*/
export const constants = {
// `access` modes.
F_OK: 0,
X_OK: 1,
W_OK: 2,
R_OK: 4,
// `open` flags.
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
O_CREAT: 64,
O_EXCL: 128,
O_TRUNC: 512,
O_APPEND: 1024,
// `copyFile` modes.
COPYFILE_EXCL: 1,
// File-type bits, for the predicates on `Stats` that cannot be answered from
// the module's coarse `kind` alone (a socket and a block device are both
// "other" there).
S_IFMT: 0o170000,
S_IFREG: 0o100000,
S_IFDIR: 0o040000,
S_IFLNK: 0o120000,
S_IFBLK: 0o060000,
S_IFCHR: 0o020000,
S_IFIFO: 0o010000,
S_IFSOCK: 0o140000,
} as const;
export type Constants = typeof constants;
+769
View File
@@ -0,0 +1,769 @@
/**
* `@react-native-gpui/fs`: a Node-shaped file API for the desktop app.
*
* The native module underneath is one TurboModule on its own thread with
* `ArrayBuffer` arguments and plain-object results. Everything Node adds on top
* of that — encodings, `Stats` and `Dirent` instances, callback forms, the
* `fs/promises` object — lives in this package's JS layer, so the bridge stays a
* byte mover and the API stays recognisably Node's.
*
* The default export is the `node:fs` object; `dialog`, `paths` and `watch` are
* named exports because they have no `fs` counterpart to hang off.
*/
import { constants } from './constants';
import NativeFs from './NativeFs';
import {
Dirent,
FileHandle,
Stats,
promises,
readFileImpl,
type MkdirOptions,
type OpenOptions,
type RmOptions,
type WriteOptions,
} from './promises';
import {
decodeString,
flagFrom,
missingPath,
modeFrom,
normalizeEncoding,
relativeEntryName,
toArrayBuffer,
toPath,
withContext,
type BinaryData,
type Encoding,
type NodeError,
} from './shared';
import { watch } from './watch';
export type {
CpOptions,
MkdirOptions,
OpenOptions,
ReaddirOptions,
RmOptions,
TimeLike,
WriteOptions,
} from './promises';
export type { BinaryData, Encoding, NodeError } from './shared';
export type {
FSWatcher,
WatchEventType,
WatchListener,
WatchOptions,
} from './watch';
export { Dirent, Stats, FileHandle, promises, constants, watch };
/* -------------------------------------------------------------------------- */
/* Dialogs */
/* -------------------------------------------------------------------------- */
export interface DialogFilter {
name: string;
extensions: Array<string>;
}
export interface OpenDialogOptions {
title?: string;
defaultPath?: string;
multiple?: boolean;
directory?: boolean;
filters?: Array<DialogFilter>;
}
export interface SaveDialogOptions {
title?: string;
defaultPath?: string;
defaultName?: string;
filters?: Array<DialogFilter>;
}
/**
* The native pickers.
*
* Cancelling is not an error: `open` resolves with `[]` and `save` with `null`,
* which is what the design specifies and what libraries pairing the two expect.
*/
export const dialog = {
async open(options: OpenDialogOptions = {}): Promise<string[]> {
return NativeFs.showOpenDialog({
title: options.title,
defaultPath:
options.defaultPath === undefined
? undefined
: toPath(options.defaultPath),
multiple: options.multiple ?? false,
directory: options.directory ?? false,
filters: options.filters ?? [],
});
},
async save(options: SaveDialogOptions = {}): Promise<string | null> {
return NativeFs.showSaveDialog({
title: options.title,
defaultPath:
options.defaultPath === undefined
? undefined
: toPath(options.defaultPath),
defaultName: options.defaultName,
filters: options.filters ?? [],
});
},
};
/* -------------------------------------------------------------------------- */
/* Known paths */
/* -------------------------------------------------------------------------- */
/** The well-known directories, as the native side reports them. */
export interface KnownPaths {
home: string;
documents: string;
downloads: string;
desktop: string;
temp: string;
appData: string;
appCache: string;
appLogs: string;
}
const KNOWN_PATH_NAMES = [
'home',
'documents',
'downloads',
'desktop',
'temp',
'appData',
'appCache',
'appLogs',
] as const;
let cachedPaths: KnownPaths | null = null;
/**
* Reads the known paths once and remembers them.
*
* Lazy rather than at import time so that importing this package cannot fail: the
* native module is only asked when an app actually names a directory, and the
* native side creates the per-app folders on that first read.
*/
function knownPaths(): KnownPaths {
if (cachedPaths === null) {
cachedPaths = NativeFs.getPaths();
}
return cachedPaths;
}
/**
* `paths.home`, `paths.documents` and the rest.
*
* Each property is a getter over one cached native read, so destructuring works
* and the object can be exported before the module has been asked anything.
*/
export const paths: KnownPaths = Object.defineProperties(
{},
Object.fromEntries(
KNOWN_PATH_NAMES.map(name => [
name,
{ enumerable: true, get: () => knownPaths()[name] },
]),
),
) as KnownPaths;
/* -------------------------------------------------------------------------- */
/* Sync forms */
/* -------------------------------------------------------------------------- */
function readFileSync(path: string): Uint8Array;
function readFileSync(
path: string,
options: Encoding | { encoding: Encoding },
): string;
function readFileSync(
path: string,
options?: Encoding | { encoding?: Encoding | null } | null,
): Uint8Array | string {
const target = toPath(path);
const encoding =
typeof options === 'string' ? options : (options?.encoding ?? null);
try {
if (encoding === null) {
return new Uint8Array(NativeFs.readFileSync(target));
}
const normalized = normalizeEncoding(encoding) ?? 'utf8';
if (normalized === 'utf8') {
// The dedicated text method decodes in Rust instead of handing JS bytes
// to decode a second time.
return NativeFs.readTextFileSync(target);
}
return decodeString(
new Uint8Array(NativeFs.readFileSync(target)),
normalized,
);
} catch (error) {
throw withContext(error, 'open', target);
}
}
function writeFileSync(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): void {
const target = toPath(path);
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
const flag = flagFrom(
opts.flag,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
);
const mode = modeFrom(opts.mode);
try {
if (typeof data === 'string') {
const encoding = normalizeEncoding(opts.encoding ?? 'utf8') ?? 'utf8';
if (encoding === 'utf8') {
NativeFs.writeTextFileSync(target, data, flag, mode);
return;
}
NativeFs.writeFileSync(target, toArrayBuffer(data, encoding), flag, mode);
return;
}
NativeFs.writeFileSync(target, toArrayBuffer(data), flag, mode);
} catch (error) {
throw withContext(error, 'open', target);
}
}
function appendFileSync(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): void {
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
writeFileSync(path, data, {
...opts,
flag:
opts.flag ?? constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND,
});
}
function existsSync(path: string): boolean {
try {
return NativeFs.existsSync(toPath(path));
} catch {
// `existsSync` answers a question rather than reporting a failure, so every
// error — a permission problem as much as a missing path — is a `false`.
return false;
}
}
function statSync(path: string): Stats {
const target = toPath(path);
try {
const result = NativeFs.statSync(target, true);
if (result === null) {
throw missingPath(target, 'stat');
}
return new Stats(result);
} catch (error) {
throw withContext(error, 'stat', target);
}
}
function lstatSync(path: string): Stats {
const target = toPath(path);
try {
const result = NativeFs.statSync(target, false);
if (result === null) {
throw missingPath(target, 'lstat');
}
return new Stats(result);
} catch (error) {
throw withContext(error, 'lstat', target);
}
}
function readdirSync(
path: string,
options?: { withFileTypes?: false; recursive?: boolean } | null,
): string[];
function readdirSync(
path: string,
options: { withFileTypes: true; recursive?: boolean },
): Dirent[];
function readdirSync(
path: string,
options?: { withFileTypes?: boolean; recursive?: boolean } | null,
): string[] | Dirent[] {
const target = toPath(path);
try {
const recursive = options?.recursive ?? false;
const entries = NativeFs.readdirSync(target, recursive);
return options?.withFileTypes
? entries.map(entry => new Dirent(entry))
: entries.map(entry =>
recursive
? relativeEntryName(target, entry.parentPath, entry.name)
: entry.name,
);
} catch (error) {
throw withContext(error, 'scandir', target);
}
}
function mkdirSync(path: string, options?: MkdirOptions | number): void {
const target = toPath(path);
const opts: MkdirOptions =
typeof options === 'number' ? { mode: options } : (options ?? {});
try {
NativeFs.mkdirSync(
target,
opts.recursive ?? false,
modeFrom(opts.mode, 0o777),
);
} catch (error) {
throw withContext(error, 'mkdir', target);
}
}
function rmSync(path: string, options?: RmOptions): void {
const target = toPath(path);
try {
NativeFs.rmSync(
target,
options?.recursive ?? false,
options?.force ?? false,
);
} catch (error) {
throw withContext(error, 'rm', target);
}
}
function renameSync(from: string, to: string): void {
const source = toPath(from);
const destination = toPath(to);
try {
NativeFs.renameSync(source, destination);
} catch (error) {
const wrapped = withContext(error, 'rename', source);
// Node's rename error names both ends; the second is `dest`, not `path`.
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
function copyFileSync(from: string, to: string): void {
const source = toPath(from);
const destination = toPath(to);
try {
NativeFs.copyFileSync(source, destination);
} catch (error) {
const wrapped = withContext(error, 'copyfile', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
/* -------------------------------------------------------------------------- */
/* Callback forms */
/* -------------------------------------------------------------------------- */
/**
* The callback forms are wrappers over the promise implementations, as the
* design says, so there is one implementation of each operation.
*
* The trailing argument is the callback in every form; the optional argument
* before it is an options object (or an encoding, or a mode) when it is not a
* function. That is the whole of Node's overloading convention here.
*/
type NodeCallback<T> = (error: NodeError | null, value?: T) => void;
function callbackOf<T>(args: unknown[]): NodeCallback<T> {
const callback = args[args.length - 1];
if (typeof callback !== 'function') {
throw new TypeError('A callback function is required');
}
return callback as NodeCallback<T>;
}
/** The options argument, if the caller supplied one rather than the callback. */
function before<T>(args: unknown[], index: number, fallback: T): T {
const value = args[index];
return value === undefined || typeof value === 'function'
? fallback
: (value as T);
}
/**
* Reports a settled promise the Node way.
*
* A rejection that has a callback is delivered to it; nothing is thrown, because
* a throw from here would escape the caller's stack rather than the callback.
*/
function settle<T>(promise: Promise<T>, callback: NodeCallback<T>): void {
promise.then(
value => callback(null, value),
(error: NodeError) => callback(error),
);
}
function readFile(
path: string,
callback: NodeCallback<Uint8Array | string>,
): void;
function readFile(
path: string,
options: Encoding | { encoding?: Encoding | null } | null,
callback: NodeCallback<Uint8Array | string>,
): void;
function readFile(path: string, ...args: unknown[]): void {
// The promise overloads keep the result precise; the callback form goes
// through the coarse alias because at that point the encoding is a runtime
// value and either shape is possible.
const options = before(args, 0, undefined) as
Encoding | { encoding?: Encoding | null } | null | undefined;
settle(readFileImpl(path, options), callbackOf(args));
}
function writeFile(
path: string,
data: BinaryData,
callback: NodeCallback<void>,
): void;
function writeFile(
path: string,
data: BinaryData,
options: WriteOptions | Encoding | null,
callback: NodeCallback<void>,
): void;
function writeFile(path: string, data: BinaryData, ...args: unknown[]): void {
settle(
promises.writeFile(
path,
data,
before(args, 0, undefined) as WriteOptions | Encoding | undefined,
),
callbackOf(args),
);
}
function appendFile(
path: string,
data: BinaryData,
callback: NodeCallback<void>,
): void;
function appendFile(
path: string,
data: BinaryData,
options: WriteOptions | Encoding | null,
callback: NodeCallback<void>,
): void;
function appendFile(path: string, data: BinaryData, ...args: unknown[]): void {
settle(
promises.appendFile(
path,
data,
before(args, 0, undefined) as WriteOptions | Encoding | undefined,
),
callbackOf(args),
);
}
function readdir(
path: string,
callback: NodeCallback<string[] | Dirent[]>,
): void;
function readdir(
path: string,
options: { withFileTypes?: boolean; recursive?: boolean } | null,
callback: NodeCallback<string[] | Dirent[]>,
): void;
function readdir(path: string, ...args: unknown[]): void {
const options = before(args, 0, null) as {
withFileTypes?: boolean;
recursive?: boolean;
} | null;
const pending: Promise<string[] | Dirent[]> = options?.withFileTypes
? promises.readdir(path, { ...options, withFileTypes: true })
: promises.readdir(path, {
recursive: options?.recursive,
withFileTypes: false,
});
settle(pending, callbackOf(args));
}
function stat(path: string, callback: NodeCallback<Stats>): void;
function stat(path: string, ...args: unknown[]): void {
settle(promises.stat(path), callbackOf([...args]));
}
function lstat(path: string, callback: NodeCallback<Stats>): void;
function lstat(path: string, ...args: unknown[]): void {
settle(promises.lstat(path), callbackOf([...args]));
}
function mkdir(path: string, callback: NodeCallback<string | undefined>): void;
function mkdir(
path: string,
options: MkdirOptions | number | null,
callback: NodeCallback<string | undefined>,
): void;
function mkdir(path: string, ...args: unknown[]): void {
settle(
promises.mkdir(
path,
before(args, 0, undefined) as MkdirOptions | number | undefined,
),
callbackOf(args),
);
}
function rm(path: string, callback: NodeCallback<void>): void;
function rm(
path: string,
options: RmOptions | null,
callback: NodeCallback<void>,
): void;
function rm(path: string, ...args: unknown[]): void {
settle(
promises.rm(path, before(args, 0, undefined) as RmOptions | undefined),
callbackOf(args),
);
}
function rename(from: string, to: string, callback: NodeCallback<void>): void;
function rename(from: string, to: string, ...args: unknown[]): void {
settle(promises.rename(from, to), callbackOf([...args]));
}
function copyFile(from: string, to: string, callback: NodeCallback<void>): void;
function copyFile(
from: string,
to: string,
mode: number,
callback: NodeCallback<void>,
): void;
function copyFile(from: string, to: string, ...args: unknown[]): void {
settle(
promises.copyFile(
from,
to,
before(args, 0, undefined) as number | undefined,
),
callbackOf(args),
);
}
function cp(from: string, to: string, callback: NodeCallback<void>): void;
function cp(
from: string,
to: string,
options: { recursive?: boolean } | null,
callback: NodeCallback<void>,
): void;
function cp(from: string, to: string, ...args: unknown[]): void {
settle(
promises.cp(
from,
to,
before(args, 0, undefined) as { recursive?: boolean } | undefined,
),
callbackOf(args),
);
}
function access(path: string, callback: NodeCallback<void>): void;
function access(path: string, mode: number, callback: NodeCallback<void>): void;
function access(path: string, ...args: unknown[]): void {
settle(
promises.access(path, before(args, 0, undefined) as number | undefined),
callbackOf(args),
);
}
function realpath(path: string, callback: NodeCallback<string>): void;
function realpath(path: string, ...args: unknown[]): void {
settle(promises.realpath(path), callbackOf([...args]));
}
function readlink(path: string, callback: NodeCallback<string>): void;
function readlink(path: string, ...args: unknown[]): void {
settle(promises.readlink(path), callbackOf([...args]));
}
function symlink(
target: string,
path: string,
callback: NodeCallback<void>,
): void;
function symlink(
target: string,
path: string,
type: string,
callback: NodeCallback<void>,
): void;
function symlink(target: string, path: string, ...args: unknown[]): void {
settle(
promises.symlink(
target,
path,
before(args, 0, undefined) as 'file' | 'dir' | 'junction' | undefined,
),
callbackOf(args),
);
}
function truncate(path: string, callback: NodeCallback<void>): void;
function truncate(
path: string,
length: number,
callback: NodeCallback<void>,
): void;
function truncate(path: string, ...args: unknown[]): void {
settle(
promises.truncate(path, before(args, 0, undefined) as number | undefined),
callbackOf(args),
);
}
function utimes(
path: string,
atime: Date | number | string,
mtime: Date | number | string,
callback: NodeCallback<void>,
): void;
function utimes(
path: string,
atime: Date | number | string,
mtime: Date | number | string,
...args: unknown[]
): void {
settle(promises.utimes(path, atime, mtime), callbackOf([...args]));
}
function chmod(
path: string,
mode: number | string,
callback: NodeCallback<void>,
): void;
function chmod(path: string, mode: number | string, ...args: unknown[]): void {
settle(promises.chmod(path, mode), callbackOf([...args]));
}
function mkdtemp(prefix: string, callback: NodeCallback<string>): void;
function mkdtemp(
prefix: string,
options: Encoding | { encoding?: Encoding },
callback: NodeCallback<string>,
): void;
function mkdtemp(prefix: string, ...args: unknown[]): void {
settle(
promises.mkdtemp(
prefix,
before(args, 0, undefined) as
Encoding | { encoding?: Encoding } | undefined,
),
callbackOf(args),
);
}
function open(path: string, callback: NodeCallback<FileHandle>): void;
function open(
path: string,
flags: string | number | OpenOptions,
callback: NodeCallback<FileHandle>,
): void;
function open(
path: string,
flags: string | number | OpenOptions,
mode: number | string,
callback: NodeCallback<FileHandle>,
): void;
function open(path: string, ...args: unknown[]): void {
const flags = before(args, 0, undefined) as
string | number | OpenOptions | undefined;
const mode = before(args, 1, undefined) as number | string | undefined;
settle(
mode === undefined
? promises.open(path, flags)
: promises.open(path, flags, mode),
callbackOf(args),
);
}
function reveal(path: string, callback: NodeCallback<void>): void;
function reveal(path: string, ...args: unknown[]): void {
settle(promises.reveal(path), callbackOf([...args]));
}
function openExternal(path: string, callback: NodeCallback<void>): void;
function openExternal(path: string, ...args: unknown[]): void {
settle(promises.openExternal(path), callbackOf([...args]));
}
/* -------------------------------------------------------------------------- */
/* The `fs` object */
/* -------------------------------------------------------------------------- */
/**
* Node's `fs`, as far as this platform implements it.
*
* The callback forms wrap the promise implementations; the sync forms call the
* bridge's JS-thread methods directly; `promises` is the same object shape Node
* exposes as `fs.promises`.
*/
const fs = {
constants,
promises,
readFile,
writeFile,
appendFile,
readdir,
stat,
lstat,
mkdir,
rm,
rename,
copyFile,
cp,
access,
realpath,
readlink,
symlink,
truncate,
utimes,
chmod,
mkdtemp,
open,
reveal,
openExternal,
readFileSync,
writeFileSync,
appendFileSync,
existsSync,
statSync,
lstatSync,
readdirSync,
mkdirSync,
rmSync,
renameSync,
copyFileSync,
Stats,
Dirent,
FileHandle,
};
export default fs;
+771
View File
@@ -0,0 +1,771 @@
/**
* `fs.promises`: the Node-shaped asynchronous face of the module.
*
* Every call here is a thin translation. The native side does the work on its own
* thread and reports Node codes, so this layer's job is argument shapes, the
* encodings Node implements above the bytes, and the fields (`syscall`, `path`,
* `dest`) that only a wrapper can know.
*/
import NativeFs from './NativeFs';
import { constants } from './constants';
import {
checkBufferRange,
decodeString,
encodeString,
flagFrom,
modeFrom,
nodeError,
normalizeEncoding,
relativeEntryName,
toArrayBuffer,
toBytes,
toPath,
withContext,
type BinaryData,
type Encoding,
type NormalizedEncoding,
} from './shared';
import { Dirent, Stats, type DirEntryResult, type StatResult } from './stats';
/** An encoding, an options object, or nothing, as Node accepts for reads. */
export type ReadOptions =
Encoding | { encoding?: Encoding | null } | null | undefined;
export interface WriteOptions {
encoding?: Encoding | null;
mode?: number | string;
flag?: string | number;
}
export interface MkdirOptions {
recursive?: boolean;
mode?: number | string;
}
export interface RmOptions {
recursive?: boolean;
force?: boolean;
}
export interface ReaddirOptions {
withFileTypes?: boolean;
recursive?: boolean;
}
export interface CpOptions {
recursive?: boolean;
}
export interface OpenOptions {
flags?: string | number;
mode?: number | string;
}
/** The chunk size for a read that has no caller-supplied buffer. */
const READ_CHUNK = 64 * 1024;
/** A time as Node accepts one: `Date`, milliseconds, or a date string. */
export type TimeLike = Date | number | string;
function toMillis(value: TimeLike, name: string): number {
if (value instanceof Date) {
return value.getTime();
}
if (typeof value === 'number') {
return value;
}
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) {
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument '${name}' is invalid time. Received '${value}'`,
);
}
return parsed;
}
function encodingOf(options: ReadOptions): NormalizedEncoding | null {
if (typeof options === 'string') {
return normalizeEncoding(options);
}
return normalizeEncoding(options?.encoding ?? null);
}
/**
* `readFile`, in whichever of the two shapes the caller asked for.
*
* UTF-8 goes through the dedicated text method: the native side decodes it while
* the bytes are still in Rust, so a text read never allocates an `ArrayBuffer`
* that JS then copies. Every other encoding is decoded here, because Node's
* `utf16le`, `latin1`, `base64` and `hex` are JS-level conventions rather than
* something `std::fs` has an opinion about.
*/
function readFile(path: string): Promise<Uint8Array>;
function readFile(
path: string,
options: { encoding?: null } | null | undefined,
): Promise<Uint8Array>;
function readFile(
path: string,
options: Encoding | { encoding: Encoding },
): Promise<string>;
async function readFile(
path: string,
options?: ReadOptions,
): Promise<Uint8Array | string> {
const target = toPath(path);
const encoding = encodingOf(options);
try {
if (encoding === 'utf8') {
return await NativeFs.readTextFile(target);
}
const buffer = await NativeFs.readFile(target);
const bytes = new Uint8Array(buffer);
return encoding === null ? bytes : decodeString(bytes, encoding);
} catch (error) {
throw withContext(error, 'open', target);
}
}
/**
* The same read, typed coarsely.
*
* `readFile`'s public overloads are what a caller sees; the callback layer calls
* through this alias because at that point the encoding is a runtime value and
* the result is genuinely either shape.
*/
export const readFileImpl: (
path: string,
options?: ReadOptions,
) => Promise<Uint8Array | string> = readFile;
/**
* `writeFile`.
*
* A string is written through `writeTextFile` when it is UTF-8; anything else —
* a different encoding, or binary data — is normalised to an `ArrayBuffer` and
* sent through the byte method.
*/
async function writeFile(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): Promise<void> {
const target = toPath(path);
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
const flag = flagFrom(
opts.flag,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
);
const mode = modeFrom(opts.mode);
try {
if (typeof data === 'string') {
// `normalizeEncoding` answers `null` only when it is given nothing, and a
// default is supplied here, so the fallback to `utf8` is unreachable — it
// exists to keep the type honest instead of asserting it away.
const encoding = normalizeEncoding(opts.encoding ?? 'utf8') ?? 'utf8';
if (encoding === 'utf8') {
await NativeFs.writeTextFile(target, data, flag, mode);
} else {
await NativeFs.writeFile(
target,
toArrayBuffer(encodeString(data, encoding)),
flag,
mode,
);
}
return;
}
await NativeFs.writeFile(target, toArrayBuffer(data), flag, mode);
} catch (error) {
throw withContext(error, 'open', target);
}
}
async function appendFile(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): Promise<void> {
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
return writeFile(path, data, {
...opts,
flag:
opts.flag ?? constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND,
});
}
function readdir(
path: string,
options?: { withFileTypes?: false; recursive?: boolean } | null,
): Promise<string[]>;
function readdir(
path: string,
options: { withFileTypes: true; recursive?: boolean },
): Promise<Dirent[]>;
async function readdir(
path: string,
options?: ReaddirOptions | null,
): Promise<string[] | Dirent[]> {
const target = toPath(path);
try {
const recursive = options?.recursive ?? false;
const entries = await NativeFs.readdir(target, recursive);
return options?.withFileTypes
? entries.map(entry => new Dirent(entry as DirEntryResult))
: entries.map(entry =>
recursive
? relativeEntryName(target, entry.parentPath, entry.name)
: entry.name,
);
} catch (error) {
throw withContext(error, 'scandir', target);
}
}
async function stat(path: string): Promise<Stats> {
const target = toPath(path);
try {
return new Stats((await NativeFs.stat(target, true)) as StatResult);
} catch (error) {
throw withContext(error, 'stat', target);
}
}
async function lstat(path: string): Promise<Stats> {
const target = toPath(path);
try {
return new Stats((await NativeFs.stat(target, false)) as StatResult);
} catch (error) {
throw withContext(error, 'lstat', target);
}
}
async function mkdir(
path: string,
options?: MkdirOptions | number,
): Promise<string | undefined> {
const target = toPath(path);
const opts: MkdirOptions =
typeof options === 'number' ? { mode: options } : (options ?? {});
try {
await NativeFs.mkdir(
target,
opts.recursive ?? false,
modeFrom(opts.mode, 0o777),
);
return undefined;
} catch (error) {
throw withContext(error, 'mkdir', target);
}
}
async function rm(path: string, options?: RmOptions): Promise<void> {
const target = toPath(path);
try {
await NativeFs.rm(
target,
options?.recursive ?? false,
options?.force ?? false,
);
} catch (error) {
throw withContext(error, 'rm', target);
}
}
async function rename(from: string, to: string): Promise<void> {
const source = toPath(from);
const destination = toPath(to);
try {
await NativeFs.rename(source, destination);
} catch (error) {
// Node's rename error names both ends; the second is `dest`, not `path`.
const wrapped = withContext(error, 'rename', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
async function copyFile(
from: string,
to: string,
mode?: number,
): Promise<void> {
const source = toPath(from);
const destination = toPath(to);
if (mode !== undefined && (mode & constants.COPYFILE_EXCL) !== 0) {
// The native copy has no exclusive flag, so the check is made here. It is
// advisory — a file created between the two calls still wins — but it gives
// the caller the error Node promises for the ordinary case.
try {
await NativeFs.stat(destination, false);
throw nodeError(
'EEXIST',
`EEXIST: file already exists, copyfile '${source}' -> '${destination}'`,
);
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
}
try {
await NativeFs.copy(source, destination, false);
} catch (error) {
const wrapped = withContext(error, 'copyfile', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
async function cp(
from: string,
to: string,
options?: CpOptions,
): Promise<void> {
const source = toPath(from);
const destination = toPath(to);
try {
await NativeFs.copy(source, destination, options?.recursive ?? false);
} catch (error) {
const wrapped = withContext(error, 'cp', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
async function access(path: string, mode?: number): Promise<void> {
const target = toPath(path);
try {
await NativeFs.access(target, mode ?? constants.F_OK);
} catch (error) {
throw withContext(error, 'access', target);
}
}
async function realpath(path: string): Promise<string> {
const target = toPath(path);
try {
return await NativeFs.realpath(target);
} catch (error) {
throw withContext(error, 'realpath', target);
}
}
async function readlink(path: string): Promise<string> {
const target = toPath(path);
try {
return await NativeFs.readlink(target);
} catch (error) {
throw withContext(error, 'readlink', target);
}
}
async function symlink(
target: string,
path: string,
type?: 'file' | 'dir' | 'junction',
): Promise<void> {
// `type` is passed through rather than dropped. It is the only way to ask for a
// directory link on Windows, where creating one as a file link leaves a path
// that cannot be traversed; Unix ignores it, as the syscall does.
const link = toPath(path);
try {
await NativeFs.symlink(toPath(target), link, type ?? null);
} catch (error) {
throw withContext(error, 'symlink', link);
}
}
async function truncate(path: string, length?: number): Promise<void> {
const target = toPath(path);
try {
await NativeFs.truncate(target, length ?? 0);
} catch (error) {
throw withContext(error, 'truncate', target);
}
}
async function utimes(
path: string,
atime: TimeLike,
mtime: TimeLike,
): Promise<void> {
const target = toPath(path);
try {
await NativeFs.utimes(
target,
toMillis(atime, 'atime'),
toMillis(mtime, 'mtime'),
);
} catch (error) {
throw withContext(error, 'utime', target);
}
}
async function chmod(path: string, mode: number | string): Promise<void> {
const target = toPath(path);
try {
await NativeFs.chmod(target, modeFrom(mode));
} catch (error) {
throw withContext(error, 'chmod', target);
}
}
async function mkdtemp(
prefix: string,
options?: Encoding | { encoding?: Encoding },
): Promise<string> {
// The native side creates the directory under the system temp directory and
// returns the path; the encoding only affects how a non-ASCII prefix arrives,
// and every string crosses the bridge as UTF-8.
options;
try {
return await NativeFs.mkdtemp(toPath(prefix));
} catch (error) {
throw withContext(error, 'mkdtemp', prefix);
}
}
/**
* A descriptor plus the operations Node puts on `FileHandle`.
*
* Positioned reads and writes do not touch the file's offset, which is what
* makes concurrent chunked I/O safe: two readers on one handle cannot move each
* other's cursor.
*/
export class FileHandle {
/** The native descriptor. */
readonly fd: number;
private closed = false;
constructor(fd: number) {
this.fd = fd;
}
private assertOpen(): void {
if (this.closed) {
throw nodeError('EBADF', `EBADF: bad file descriptor, ${this.fd}`);
}
}
/** Reads from the current offset when `position` is `-1`. */
async read(): Promise<{ bytesRead: number; buffer: Uint8Array }>;
async read(options: {
buffer?: Uint8Array;
offset?: number;
length?: number;
position?: number | null;
}): Promise<{ bytesRead: number; buffer: Uint8Array }>;
async read(
buffer: Uint8Array,
offset?: number,
length?: number,
position?: number | null,
): Promise<{ bytesRead: number; buffer: Uint8Array }>;
/**
* Node's positional form with the buffer implied: `read(size, offset, length,
* position)`. `offset` and `length` are a window into the freshly allocated
* buffer, and a missing `position` reads from the cursor.
*/
async read(
size: number,
offset?: number,
length?: number,
position?: number | null,
): Promise<{ bytesRead: number; buffer: Uint8Array }>;
async read(
first?:
| number
| Uint8Array
| {
buffer?: Uint8Array;
offset?: number;
length?: number;
position?: number | null;
},
second?: number,
third?: number,
fourth?: number | null,
): Promise<{ bytesRead: number; buffer: Uint8Array }> {
this.assertOpen();
let destination: Uint8Array;
let offset = 0;
let length: number;
let position: number;
// The two no-buffer forms mean "read to the end", which is a different
// operation from a single bounded read and is handled by `readToEnd`.
let toEnd = false;
if (first === undefined) {
toEnd = true;
destination = new Uint8Array(0);
length = 0;
position = -1;
} else if (typeof first === 'number') {
// A size instead of a buffer: allocate it and apply the same window
// arguments Node's buffer form takes.
destination = new Uint8Array(first);
offset = second ?? 0;
length = third ?? Math.max(destination.byteLength - offset, 0);
position = fourth ?? -1;
} else if (first instanceof Uint8Array) {
destination = first;
offset = second ?? 0;
length = third ?? destination.byteLength - offset;
position = fourth ?? -1;
} else {
destination = first.buffer ?? new Uint8Array(0);
offset = first.offset ?? 0;
length = first.length ?? Math.max(destination.byteLength - offset, 0);
position = first.position ?? -1;
toEnd = first.buffer === undefined;
}
try {
if (toEnd) {
const bytes = await this.readToEnd(position);
return { bytesRead: bytes.byteLength, buffer: bytes };
}
// Validated before the native call, not after. The native side reads
// `length` bytes and advances the cursor, so a window that overruns the
// buffer used to consume bytes and then drop them when the result was
// trimmed to fit. Node rejects the call instead, and so must this.
const window = checkBufferRange(destination, offset, length);
const buffer = await NativeFs.read(this.fd, window.length, position);
const bytes = new Uint8Array(buffer);
const bytesRead = Math.min(bytes.byteLength, window.length);
destination.set(bytes.subarray(0, bytesRead), window.offset);
return { bytesRead, buffer: destination };
} catch (error) {
throw withContext(error, 'read', undefined);
}
}
/**
* Reads until a short read, which is end of file.
*
* The native `read` returns at most what it was asked for, so "the rest of the
* file" has to be assembled: a single call with a huge length would ask Rust to
* allocate that much up front. A cursor read (`position` -1) advances the
* handle's offset, so the loop continues where the previous chunk ended.
*/
private async readToEnd(position: number): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let total = 0;
let at = position;
for (;;) {
const buffer = await NativeFs.read(this.fd, READ_CHUNK, at);
const chunk = new Uint8Array(buffer);
if (chunk.byteLength === 0) {
break;
}
chunks.push(chunk);
total += chunk.byteLength;
if (chunk.byteLength < READ_CHUNK) {
break;
}
if (at >= 0) {
at += chunk.byteLength;
}
}
const all = new Uint8Array(total);
let cursor = 0;
for (const chunk of chunks) {
all.set(chunk, cursor);
cursor += chunk.byteLength;
}
return all;
}
async write(
data: BinaryData,
position?: number | null,
encoding?: Encoding,
): Promise<{ bytesWritten: number; buffer: Uint8Array }>;
async write(
buffer: Uint8Array,
offset: number,
length: number,
position?: number | null,
): Promise<{ bytesWritten: number; buffer: Uint8Array }>;
async write(
data: BinaryData,
second?: number | null,
third?: number | Encoding,
fourth?: number | null,
): Promise<{ bytesWritten: number; buffer: Uint8Array }> {
this.assertOpen();
let bytes: Uint8Array;
let position: number | null = null;
if (typeof data === 'string') {
// `write(string, position, encoding)`: here the number is a file position.
const encodingName =
typeof third === 'string' ? normalizeEncoding(third) : 'utf8';
bytes = encodeString(data, encodingName ?? 'utf8');
position = second ?? null;
} else if (
data instanceof Uint8Array &&
third !== undefined &&
typeof third !== 'number'
) {
// `write(buffer, { offset, length, position })`, which Node also accepts.
const options = third as unknown as {
offset?: number;
length?: number;
position?: number | null;
};
const window = checkBufferRange(data, options.offset, options.length);
bytes = data.subarray(window.offset, window.offset + window.length);
position = options.position ?? null;
} else if (
data instanceof Uint8Array &&
second !== undefined &&
second !== null
) {
// `write(buffer, offset[, length[, position]])` — the numeric argument is
// into the *buffer*, not into the file. Reading it as a file position
// wrote the whole buffer at that offset instead of a slice at the cursor.
const window = checkBufferRange(
data,
second,
typeof third === 'number' ? third : undefined,
);
bytes = data.subarray(window.offset, window.offset + window.length);
position = fourth ?? null;
} else {
bytes = toBytes(data);
position = typeof second === 'number' ? second : null;
}
try {
const bytesWritten = await NativeFs.write(
this.fd,
toArrayBuffer(bytes),
position ?? -1,
);
return { bytesWritten, buffer: bytes };
} catch (error) {
throw withContext(error, 'write', undefined);
}
}
async stat(): Promise<Stats> {
this.assertOpen();
try {
return new Stats((await NativeFs.fstat(this.fd)) as StatResult);
} catch (error) {
throw withContext(error, 'fstat', undefined);
}
}
async sync(): Promise<void> {
this.assertOpen();
try {
await NativeFs.fsync(this.fd);
} catch (error) {
throw withContext(error, 'fsync', undefined);
}
}
async truncate(length?: number): Promise<void> {
this.assertOpen();
try {
await NativeFs.ftruncate(this.fd, length ?? 0);
} catch (error) {
throw withContext(error, 'ftruncate', undefined);
}
}
async close(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
try {
await NativeFs.close(this.fd);
} catch (error) {
throw withContext(error, 'close', undefined);
}
}
}
async function open(
path: string,
flags?: string | number | OpenOptions,
mode?: number | string,
): Promise<FileHandle> {
const target = toPath(path);
const opts: OpenOptions =
typeof flags === 'object' && flags !== null ? flags : { flags, mode };
const flag = flagFrom(opts.flags, constants.O_RDONLY);
try {
const fd = await NativeFs.open(target, flag, modeFrom(opts.mode));
return new FileHandle(fd);
} catch (error) {
throw withContext(error, 'open', target);
}
}
async function reveal(path: string): Promise<void> {
const target = toPath(path);
try {
await NativeFs.reveal(target);
} catch (error) {
throw withContext(error, 'reveal', target);
}
}
async function openExternal(path: string): Promise<void> {
const target = toPath(path);
try {
await NativeFs.openExternal(target);
} catch (error) {
throw withContext(error, 'openExternal', target);
}
}
/**
* The `fs/promises` object.
*
* The functions are declared separately and collected here so the callback layer
* in `index.ts` can wrap the same implementations rather than reimplementing
* them.
*/
export const promises = {
readFile,
writeFile,
appendFile,
readdir,
stat,
lstat,
mkdir,
rm,
rename,
copyFile,
cp,
access,
realpath,
readlink,
symlink,
truncate,
utimes,
chmod,
mkdtemp,
open,
reveal,
openExternal,
};
export type Promises = typeof promises;
/** Re-exported so `fs.stat` and `fs.promises.stat` share one class identity. */
export { Dirent, Stats };
export type { StatResult };
+539
View File
@@ -0,0 +1,539 @@
/**
* The pieces every face of the API needs: Node error objects, `file://` path
* conversion, the encodings Node supports, and the flag/mode translation.
*
* Nothing here touches the native module, so it stays usable from the sync,
* callback and promise layers without a cycle.
*/
/** Bytes in any of the shapes a caller may hand a writer. */
export type BinaryData = string | ArrayBuffer | ArrayBufferView;
/** The encodings this module implements, after aliasing. */
export type NormalizedEncoding =
'utf8' | 'utf16le' | 'latin1' | 'base64' | 'hex';
/** The encodings accepted in an options object, with Node's aliases. */
export type Encoding =
| 'utf8'
| 'utf-8'
| 'utf16le'
| 'utf-16le'
| 'ucs2'
| 'ucs-2'
| 'latin1'
| 'binary'
| 'ascii'
| 'base64'
| 'hex';
/** What a rejected call looks like: Node's `ErrnoException`. */
export interface NodeError extends Error {
code?: string;
errno?: number;
syscall?: string;
path?: string;
/** The second path of a two-path call, as Node names it on `rename` and `cp`. */
dest?: string;
}
/** Builds an error with a code but no OS call behind it. */
export function nodeError(code: string, message: string): NodeError {
const error = new Error(message) as NodeError;
error.code = code;
return error;
}
/** The error Node raises when a path is not there, in its message format. */
export function missingPath(path: string, syscall: string): NodeError {
return nodeError(
'ENOENT',
`ENOENT: no such file or directory, ${syscall} '${path}'`,
);
}
/**
* Adds the fields only the JS side knows to an error a native call rejected
* with.
*
* The Rust layer reports `code` and a Node-format message, but it deliberately
* does not guess `syscall` — one native `stat` serves `stat` and `lstat`, and it
* cannot know the spelling of the path the caller passed.
*/
export function withContext(
error: unknown,
syscall: string,
path?: string,
): NodeError {
const wrapped: NodeError =
error instanceof Error
? (error as NodeError)
: nodeError('EIO', String(error));
if (wrapped.syscall === undefined) {
wrapped.syscall = syscall;
}
if (path !== undefined && wrapped.path === undefined) {
wrapped.path = path;
}
return wrapped;
}
/**
* `file://` URLs to paths, leaving plain paths alone.
*
* Node accepts `URL` objects and `file:` strings interchangeably; this API takes
* strings, so the conversion is textual. Percent escapes are decoded because a
* path with a space arrives as `file:///a%20b`.
*/
export function toPath(path: string): string {
if (!path.startsWith('file://')) {
return path;
}
let rest = path.slice('file://'.length);
// An empty or `localhost` authority is the local machine.
if (rest.startsWith('localhost/')) {
rest = rest.slice('localhost'.length);
}
const decoded = decodeURIComponent(rest);
// `file:///C:/x` is a drive path on Windows, not a path under `/`.
if (/^\/[A-Za-z]:[\\/]/.test(decoded)) {
return decoded.slice(1);
}
return decoded;
}
/**
* Node's bounds check for a buffer window, as `RangeError` with
* `code = 'ERR_OUT_OF_RANGE'`.
*
* Both `read` and `write` take a window into a caller's buffer, and the native
* side reads or writes `length` bytes before the result is trimmed to fit — so an
* oversized `length` would consume bytes from the file and silently drop them
* rather than failing. Node rejects the call instead, and its wording is
* reproduced because callers match on it:
*
* The value of "length" is out of range. It must be <= 2. Received 8
*/
export function checkBufferRange(
buffer: Uint8Array,
offset: number | undefined,
length: number | undefined,
): { offset: number; length: number } {
const start = offset ?? 0;
if (!Number.isInteger(start) || start < 0 || start > buffer.byteLength) {
throw nodeError(
'ERR_OUT_OF_RANGE',
`The value of "offset" is out of range. It must be >= 0 && <= ${buffer.byteLength}. Received ${start}`,
);
}
const count = length ?? buffer.byteLength - start;
const available = buffer.byteLength - start;
if (!Number.isInteger(count) || count < 0 || count > available) {
throw nodeError(
'ERR_OUT_OF_RANGE',
`The value of "length" is out of range. It must be <= ${available}. Received ${count}`,
);
}
return { offset: start, length: count };
}
/**
* A recursive `readdir` entry as the path Node reports.
*
* Node's `readdir(dir, { recursive: true })` returns paths relative to `dir`
* — `nested/deep/leaf.txt`, not `leaf.txt` — so a caller can join them onto the
* root and get a real path. The native side reports each entry's absolute
* containing directory, so the relative name is what remains after removing the
* root. A non-recursive listing names the direct children only, which is already
* what `entry.name` holds, so this is used for the recursive case alone.
*
* The separator is taken from the paths themselves rather than from a platform
* check: the native side returns native separators, and a module that reaches
* for `process.platform` needs Node's types for a question the data already
* answers.
*/
export function relativeEntryName(
root: string,
parentPath: string,
name: string,
): string {
const separator = root.includes('\\') ? '\\' : '/';
const split = (value: string) => value.split(/[\\/]+/u).filter(Boolean);
const rootParts = split(root);
const parentParts = split(parentPath);
// Compare from the front. A parent that does not start with the root means the
// entry is somewhere the caller did not ask about, which should not happen, so
// the bare name is a safer answer than a fabricated path.
const inside = rootParts.every((part, index) => parentParts[index] === part);
if (!inside) {
return name;
}
return [...parentParts.slice(rootParts.length), name].join(separator);
}
/** The encoding name, folded to the set implemented here. */
export function normalizeEncoding(
encoding: string | null | undefined,
): NormalizedEncoding | null {
if (encoding === null || encoding === undefined) {
return null;
}
switch (encoding.toLowerCase()) {
case 'utf8':
case 'utf-8':
return 'utf8';
case 'utf16le':
case 'utf-16le':
case 'ucs2':
case 'ucs-2':
return 'utf16le';
case 'latin1':
case 'binary':
case 'ascii':
return 'latin1';
case 'base64':
return 'base64';
case 'hex':
return 'hex';
default:
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument 'encoding' is invalid encoding. Received '${encoding}'`,
);
}
}
const BASE64_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const BASE64_VALUES: Record<string, number> = (() => {
const values: Record<string, number> = {};
for (let index = 0; index < BASE64_ALPHABET.length; index += 1) {
values[BASE64_ALPHABET[index]] = index;
}
// The URL-safe alphabet is accepted too: `Buffer` decodes either.
values['-'] = 62;
values._ = 63;
return values;
})();
function encodeBase64(bytes: Uint8Array): string {
let out = '';
for (let index = 0; index < bytes.byteLength; index += 3) {
const first = bytes[index];
const second = index + 1 < bytes.byteLength ? bytes[index + 1] : 0;
const third = index + 2 < bytes.byteLength ? bytes[index + 2] : 0;
const triple = (first << 16) | (second << 8) | third;
out += BASE64_ALPHABET[(triple >> 18) & 0x3f];
out += BASE64_ALPHABET[(triple >> 12) & 0x3f];
out +=
index + 1 < bytes.byteLength
? BASE64_ALPHABET[(triple >> 6) & 0x3f]
: '=';
out += index + 2 < bytes.byteLength ? BASE64_ALPHABET[triple & 0x3f] : '=';
}
return out;
}
function decodeBase64(text: string): Uint8Array {
const bytes: number[] = [];
let pending = 0;
let bits = 0;
for (const character of text) {
if (
character === '=' ||
character === '\n' ||
character === '\r' ||
character === ' '
) {
continue;
}
const value = BASE64_VALUES[character];
if (value === undefined) {
// Node stops at the first character that is not base64 rather than
// failing, so `Buffer.from('abc$', 'base64')` decodes the prefix.
break;
}
pending = (pending << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
bytes.push((pending >> bits) & 0xff);
}
}
return Uint8Array.from(bytes);
}
function encodeHex(bytes: Uint8Array): string {
let out = '';
for (let index = 0; index < bytes.byteLength; index += 1) {
out += bytes[index].toString(16).padStart(2, '0');
}
return out;
}
function decodeHex(text: string): Uint8Array {
const bytes: number[] = [];
for (let index = 0; index + 1 < text.length; index += 2) {
const value = Number.parseInt(text.slice(index, index + 2), 16);
if (Number.isNaN(value)) {
break;
}
bytes.push(value);
}
return Uint8Array.from(bytes);
}
function encodeUtf8(text: string): Uint8Array {
const bytes: number[] = [];
for (let index = 0; index < text.length; index += 1) {
let code = text.charCodeAt(index);
// A surrogate pair is one code point; without this an emoji becomes two
// sequences of replacement characters.
if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
const next = text.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
index += 1;
}
}
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
} else if (code < 0x10000) {
bytes.push(
0xe0 | (code >> 12),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
} else {
bytes.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
}
}
return Uint8Array.from(bytes);
}
function decodeUtf8(bytes: Uint8Array): string {
let out = '';
let index = 0;
while (index < bytes.byteLength) {
const first = bytes[index];
const width =
first < 0x80
? 1
: first >= 0xf0
? 4
: first >= 0xe0
? 3
: first >= 0xc0
? 2
: 0;
if (width === 0 || index + width > bytes.byteLength) {
// Node decodes malformed input with U+FFFD instead of throwing, so a
// file that is not valid UTF-8 still reads as text.
out += '\ufffd';
index += 1;
continue;
}
let code = width === 1 ? first : first & (0xff >> (width + 1));
let valid = true;
for (let offset = 1; offset < width; offset += 1) {
const continuation = bytes[index + offset];
if ((continuation & 0xc0) !== 0x80) {
valid = false;
break;
}
code = (code << 6) | (continuation & 0x3f);
}
if (!valid) {
out += '\ufffd';
index += 1;
continue;
}
out += String.fromCodePoint(code);
index += width;
}
return out;
}
function encodeUtf16le(text: string): Uint8Array {
const bytes = new Uint8Array(text.length * 2);
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
bytes[index * 2] = code & 0xff;
bytes[index * 2 + 1] = code >> 8;
}
return bytes;
}
function decodeUtf16le(bytes: Uint8Array): string {
const codes: number[] = [];
for (let index = 0; index + 1 < bytes.byteLength; index += 2) {
codes.push(bytes[index] | (bytes[index + 1] << 8));
}
return chunkedFromCharCode(codes);
}
function chunkedFromCharCode(codes: number[]): string {
let out = '';
// `apply` has an argument-count limit, so long strings are built in pieces.
for (let index = 0; index < codes.length; index += 8192) {
out += String.fromCharCode.apply(null, codes.slice(index, index + 8192));
}
return out;
}
/** Text to bytes for the encodings implemented in JS. */
export function encodeString(
text: string,
encoding: NormalizedEncoding,
): Uint8Array {
switch (encoding) {
case 'utf8':
return encodeUtf8(text);
case 'utf16le':
return encodeUtf16le(text);
case 'latin1': {
const bytes = new Uint8Array(text.length);
for (let index = 0; index < text.length; index += 1) {
bytes[index] = text.charCodeAt(index) & 0xff;
}
return bytes;
}
case 'base64':
return decodeBase64(text);
case 'hex':
return decodeHex(text);
}
}
/** Bytes to text for the encodings implemented in JS. */
export function decodeString(
bytes: Uint8Array,
encoding: NormalizedEncoding,
): string {
switch (encoding) {
case 'utf8':
return decodeUtf8(bytes);
case 'utf16le':
return decodeUtf16le(bytes);
case 'latin1': {
const codes: number[] = [];
for (let index = 0; index < bytes.byteLength; index += 1) {
codes.push(bytes[index]);
}
return chunkedFromCharCode(codes);
}
case 'base64':
return encodeBase64(bytes);
case 'hex':
return encodeHex(bytes);
}
}
/**
* Normalises any accepted data shape to an `ArrayBuffer` the bridge can send.
*
* A `Uint8Array` may be a window onto a larger allocation; slicing to the view's
* own range is what stops a writer from sending bytes that belong to something
* else in the same buffer.
*/
export function toArrayBuffer(
data: BinaryData,
encoding: NormalizedEncoding = 'utf8',
): ArrayBuffer {
if (typeof data === 'string') {
const bytes = encodeString(data, encoding);
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
if (data instanceof ArrayBuffer) {
return data;
}
const view = data as ArrayBufferView;
const buffer = view.buffer as ArrayBuffer;
if (view.byteOffset === 0 && view.byteLength === buffer.byteLength) {
return buffer;
}
return buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
}
/** A byte view of `toArrayBuffer`'s result, without copying. */
export function toBytes(
data: BinaryData,
encoding: NormalizedEncoding = 'utf8',
): Uint8Array {
return new Uint8Array(toArrayBuffer(data, encoding));
}
/** Node's numeric `open` flags, by their string spelling. */
const FLAG_STRINGS: Record<string, number> = {
r: 0,
'r+': 2,
rs: 0,
sr: 0,
w: 1 | 64 | 512,
wx: 1 | 64 | 512 | 128,
xw: 1 | 64 | 512 | 128,
'w+': 2 | 64 | 512,
'wx+': 2 | 64 | 512 | 128,
'xw+': 2 | 64 | 512 | 128,
a: 1 | 64 | 1024,
ax: 1 | 64 | 1024 | 128,
xa: 1 | 64 | 1024 | 128,
'a+': 2 | 64 | 1024,
'ax+': 2 | 64 | 1024 | 128,
'xa+': 2 | 64 | 1024 | 128,
as: 1 | 64 | 1024,
sa: 1 | 64 | 1024,
};
/** A `flag` option as the number the native side decodes. */
export function flagFrom(
flag: string | number | undefined,
fallback: number,
): number {
if (flag === undefined) {
return fallback;
}
if (typeof flag === 'number') {
return flag;
}
const value = FLAG_STRINGS[flag];
if (value === undefined) {
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument 'flag' is invalid. Received '${flag}'`,
);
}
return value;
}
/** A `mode` option as a number. A string is octal, as in Node. */
export function modeFrom(
mode: number | string | undefined,
fallback = 0o666,
): number {
if (mode === undefined) {
return fallback;
}
if (typeof mode === 'number') {
return mode;
}
const parsed = Number.parseInt(mode, 8);
if (Number.isNaN(parsed)) {
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument 'mode' is invalid. Received '${mode}'`,
);
}
return parsed;
}
+196
View File
@@ -0,0 +1,196 @@
/**
* `Stats` and `Dirent`, built over the plain objects the native module returns.
*
* The module crosses the bridge with numbers rather than methods — a generated
* TurboModule result cannot carry predicates — so the classes live here and
* reproduce the shape Node's callers test for. The `kind` field is the coarse
* classification the native side can make without a platform syscall; `mode` is
* there for the finer predicates, and is zero on Windows where there are no
* Unix mode bits at all.
*/
import { constants } from './constants';
/** The native `FileKind` numbers; must match `module::kind` in Rust. */
export const FILE_KIND = {
file: 0,
directory: 1,
symlink: 2,
other: 3,
} as const;
/** The shape `stat`, `lstat` and `fstat` return across the bridge. */
export interface StatResult {
size: number;
mode: number;
kind: number;
atimeMs: number;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
dev: number;
ino: number;
nlink: number;
uid: number;
gid: number;
}
/** The shape `readdir` returns across the bridge. */
export interface DirEntryResult {
name: string;
kind: number;
parentPath: string;
}
/** The type bits of a mode, or 0 when the platform does not report them. */
function fileTypeBits(mode: number): number {
return mode & constants.S_IFMT;
}
/**
* Node's `fs.Stats`.
*
* The fields are plain properties rather than getters: they are copied once from
* the native result, and a caller destructuring the object sees the same values
* Node's does.
*/
export class Stats {
readonly dev: number;
readonly ino: number;
readonly mode: number;
readonly nlink: number;
readonly uid: number;
readonly gid: number;
readonly rdev: number;
readonly size: number;
readonly blksize: number;
readonly blocks: number;
readonly atimeMs: number;
readonly mtimeMs: number;
readonly ctimeMs: number;
readonly birthtimeMs: number;
readonly atime: Date;
readonly mtime: Date;
readonly ctime: Date;
readonly birthtime: Date;
/** The native classification, kept because it answers predicates the mode
* cannot on platforms that report no type bits. */
private readonly kind: number;
constructor(result: StatResult) {
this.dev = result.dev;
this.ino = result.ino;
this.mode = result.mode;
this.nlink = result.nlink;
this.uid = result.uid;
this.gid = result.gid;
// The native result has no `rdev`, block size or block count; Node reports
// zero for them where the platform cannot say, so zero is the honest value.
this.rdev = 0;
this.size = result.size;
this.blksize = 0;
this.blocks = 0;
this.atimeMs = result.atimeMs;
this.mtimeMs = result.mtimeMs;
this.ctimeMs = result.ctimeMs;
this.birthtimeMs = result.birthtimeMs;
this.atime = new Date(result.atimeMs);
this.mtime = new Date(result.mtimeMs);
this.ctime = new Date(result.ctimeMs);
this.birthtime = new Date(result.birthtimeMs);
this.kind = result.kind;
}
isFile(): boolean {
const bits = fileTypeBits(this.mode);
return bits === 0
? this.kind === FILE_KIND.file
: bits === constants.S_IFREG;
}
isDirectory(): boolean {
const bits = fileTypeBits(this.mode);
return bits === 0
? this.kind === FILE_KIND.directory
: bits === constants.S_IFDIR;
}
isSymbolicLink(): boolean {
const bits = fileTypeBits(this.mode);
return bits === 0
? this.kind === FILE_KIND.symlink
: bits === constants.S_IFLNK;
}
isBlockDevice(): boolean {
return fileTypeBits(this.mode) === constants.S_IFBLK;
}
isCharacterDevice(): boolean {
return fileTypeBits(this.mode) === constants.S_IFCHR;
}
isFIFO(): boolean {
return fileTypeBits(this.mode) === constants.S_IFIFO;
}
isSocket(): boolean {
return fileTypeBits(this.mode) === constants.S_IFSOCK;
}
}
/**
* Node's `fs.Dirent`.
*
* `parentPath` is `''` when the caller already held the parent, matching what
* `readdir` with `{withFileTypes: true}` produces there.
*/
export class Dirent {
readonly name: string;
readonly parentPath: string;
readonly path: string;
private readonly kind: number;
constructor(result: DirEntryResult) {
this.name = result.name;
this.parentPath = result.parentPath;
// `path` is the older name for the same field, and libraries still read it.
this.path = result.parentPath;
this.kind = result.kind;
}
isFile(): boolean {
return this.kind === FILE_KIND.file;
}
isDirectory(): boolean {
return this.kind === FILE_KIND.directory;
}
isSymbolicLink(): boolean {
return this.kind === FILE_KIND.symlink;
}
isBlockDevice(): boolean {
return false;
}
isCharacterDevice(): boolean {
return false;
}
isFIFO(): boolean {
return false;
}
isSocket(): boolean {
return false;
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* `watch`: the JS half of the typed watcher event.
*
* The native module returns a watcher id; every event it produces carries that
* id, so one shared emitter serves every open watcher and each listener keeps
* only its own. The subscription is taken through the generated typed emitter on
* the module (`NativeFs.onWatchEvent`), which is the same mechanism the fixture's
* `onProgress` uses in `RustTurboModuleFixture.tsx`, rather than through
* `NativeEventEmitter`: the spec declares `onWatchEvent` as a typed
* `CodegenTypes.EventEmitter`, and the bridge installs a listener for exactly
* that; `NativeEventEmitter` would subscribe on the global device-emitter channel
* that only `sendEvent` writes to, so it would never fire.
*/
import NativeFs, { type WatchEvent } from './NativeFs';
import { toPath, withContext, type NodeError } from './shared';
/** The event names Node uses for the two things a watcher reports. */
export type WatchEventType = 'rename' | 'change';
export interface WatchOptions {
recursive?: boolean;
encoding?: string;
}
export interface FSWatcher {
close(): Promise<void>;
}
export type WatchListener = (
eventType: WatchEventType,
filename: string | null,
) => void;
/**
* Which of Node's two event types a native kind is.
*
* Node folds everything it sees into `rename` (the entry appeared, vanished or
* moved) and `change` (its contents or metadata changed). The caller's callback
* signature is Node's, so the mapping happens here while the native kind stays
* available to anyone who subscribes to the module directly.
*/
function eventTypeOf(kind: string): WatchEventType {
return kind === 'modify' ? 'change' : 'rename';
}
/** The file a path names, relative to the watched root where it is below it. */
/**
* The filename a listener is given.
*
* The native side already reported it relative to the watch root, and its own
* name for a single-file watch: it canonicalises the root, which is where macOS
* decides between `/var` and `/private/var`, and it strips whichever spelling the
* event actually carries. Stripping a prefix here as well would be a second,
* weaker guess at the same question — a relative root never matches, and Windows
* separates with `\` — so the event path is passed through unchanged.
*/
function fileNameOf(eventPath: string): string {
return eventPath;
}
/**
* Starts a watcher.
*
* The id arrives asynchronously, but the caller expects a handle it can close
* immediately, so the handle is created first and closing it before the id is
* known sets a flag the resolution honours. That is the one ordering hazard in
* this API, and it is invisible from the outside.
*
* Node reports a failure to start through an `error` event on the watcher. This
* handle has no event surface, so the failure is held and rejected from
* `close()` instead: it can then never become an unhandled rejection, and a
* caller that closes what it opened is guaranteed to see it.
*/
export function watch(path: string, listener: WatchListener): FSWatcher;
export function watch(
path: string,
options: WatchOptions | null,
listener: WatchListener,
): FSWatcher;
export function watch(
path: string,
optionsOrListener: WatchOptions | null | WatchListener,
maybeListener?: WatchListener,
): FSWatcher {
const options =
typeof optionsOrListener === 'function' ? {} : (optionsOrListener ?? {});
const listener =
typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener;
if (typeof listener !== 'function') {
throw new TypeError('watch requires a listener function');
}
const target = toPath(path);
let id: number | null = null;
let closed = false;
let failure: NodeError | null = null;
let subscription: { remove(): void } | null = null;
const ready = NativeFs.watch(target, options.recursive ?? false)
.then(watcherId => {
id = watcherId;
if (closed) {
// `close()` ran while the id was in flight; stop the native watcher now
// that there is something to stop.
NativeFs.unwatch(watcherId);
return;
}
subscription = NativeFs.onWatchEvent((event: WatchEvent) => {
if (event.id !== id) {
return;
}
// The encoding option only affects how a non-ASCII name is decoded, and
// every name already arrives as a JS string.
listener(eventTypeOf(event.kind), fileNameOf(event.path));
});
})
.catch((error: unknown) => {
failure = withContext(error, 'watch', target);
});
return {
async close(): Promise<void> {
closed = true;
subscription?.remove();
subscription = null;
if (id !== null) {
NativeFs.unwatch(id);
id = null;
}
// Awaiting the start makes `close()` immediately after `watch()` still stop
// the native watcher rather than racing it.
await ready;
if (failure !== null) {
const error = failure;
failure = null;
throw error;
}
},
};
}
@@ -0,0 +1,19 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/gesture-handler was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
rn_gpui_find_node_module(react-native-gesture-handler _gesture_root)
if(NOT EXISTS "${_gesture_root}/src/specs/NativeRNGestureHandlerModule.ts")
message(FATAL_ERROR "react-native-gesture-handler 3.3.x was not found at ${_gesture_root}")
endif()
file(READ "${_gesture_root}/package.json" _gesture_package_json)
string(JSON _gesture_version GET "${_gesture_package_json}" version)
if(NOT _gesture_version MATCHES "^3\\.3\\.")
message(FATAL_ERROR "@react-native-gpui/gesture-handler supports react-native-gesture-handler 3.3.x, found ${_gesture_version}")
endif()
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUIGestureHandlerModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS
"${RN_GPUI_SYS_CPP_DIR}")
@@ -0,0 +1,500 @@
#include "GpuiHost.h"
#include "GpuiModules.h"
#include <GPUIGestureHandlerSpec.h>
#include <ReactCommon/TurboModule.h>
#include <folly/dynamic.h>
#include <folly/json.h>
#include <glog/logging.h>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace {
using facebook::jsi::Runtime;
using facebook::react::TurboModule;
using rngpui::extension::RawPointerEvent;
using rngpui::extension::RecognizedGesture;
constexpr int32_t kUndetermined = 0;
constexpr int32_t kFailed = 1;
constexpr int32_t kBegan = 2;
constexpr int32_t kCancelled = 3;
constexpr int32_t kActive = 4;
constexpr int32_t kEnd = 5;
struct Handler {
std::string name;
folly::dynamic config{folly::dynamic::object};
int32_t viewTag{-1};
int32_t actionType{0};
int32_t state{kUndetermined};
float startX{0};
float startY{0};
float lastX{0};
float lastY{0};
double startTime{0};
double lastTime{0};
bool recognized{false};
std::vector<int32_t> simultaneousHandlers;
std::vector<int32_t> waitFor;
std::vector<int32_t> blocksHandlers;
};
std::mutex handlersMutex;
std::unordered_map<int32_t, Handler> handlers;
folly::dynamic configObject(folly::dynamic config) {
return config.isObject() ? std::move(config) : folly::dynamic::object();
}
float number(const folly::dynamic& object, const char* key, float fallback) {
if (!object.isObject() || !object.get_ptr(key))
return fallback;
const auto& value = object[key];
if (value.isNumber())
return static_cast<float>(value.asDouble());
return fallback;
}
bool enabled(const Handler& handler) {
const auto* value = handler.config.get_ptr("enabled");
return value == nullptr || !value->isBool() || value->asBool();
}
bool isPan(const Handler& handler) {
return handler.name == "Pan" || handler.name == "PanGestureHandler";
}
bool isTap(const Handler& handler) {
return handler.name == "Tap" || handler.name == "TapGestureHandler";
}
bool isLongPress(const Handler& handler) {
return handler.name == "LongPress" ||
handler.name == "LongPressGestureHandler";
}
bool gestureTracingEnabled() {
return std::getenv("RN_GPUI_DEBUG_GESTURES") != nullptr;
}
folly::dynamic payloadFor(
int32_t handlerTag,
const Handler& handler,
int32_t state,
int32_t oldState,
const RawPointerEvent& pointer,
bool stateChange) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
const double elapsed = std::max(1.0, pointer.timestamp - handler.lastTime);
folly::dynamic payload = folly::dynamic::object("handlerTag", handlerTag)(
"state", state)("x", pointer.x - handler.startX)(
"y", pointer.y - handler.startY)("absoluteX", pointer.x)(
"absoluteY", pointer.y)("translationX", dx)("translationY", dy)(
"velocityX", (pointer.x - handler.lastX) * 1000.0 / elapsed)(
"velocityY", (pointer.y - handler.lastY) * 1000.0 / elapsed)(
"numberOfPointers", 1)("pointerType", 2)(
"duration", pointer.timestamp - handler.startTime);
if (stateChange) {
payload["oldState"] = oldState;
}
return payload;
}
void appendEvent(
std::vector<RecognizedGesture>& events,
int32_t viewTag,
const char* type,
const folly::dynamic& payload) {
events.push_back({viewTag, type, folly::toJson(payload)});
}
void appendState(
std::vector<RecognizedGesture>& events,
int32_t handlerTag,
Handler& handler,
int32_t nextState,
const RawPointerEvent& pointer) {
if (handler.state == nextState)
return;
const auto oldState = handler.state;
handler.state = nextState;
if (nextState == kActive) {
handler.recognized = true;
}
appendEvent(
events,
handler.viewTag,
"topGestureHandlerStateChange",
payloadFor(handlerTag, handler, nextState, oldState, pointer, true));
}
std::vector<int32_t> relationTags(
const folly::dynamic& relations,
const char* key) {
std::vector<int32_t> tags;
const auto* values = relations.get_ptr(key);
if (values == nullptr || !values->isArray()) {
return tags;
}
tags.reserve(values->size());
for (const auto& value : *values) {
if (value.isInt()) {
tags.push_back(static_cast<int32_t>(value.asInt()));
}
}
return tags;
}
bool hasTag(const std::vector<int32_t>& tags, int32_t tag) {
return std::find(tags.begin(), tags.end(), tag) != tags.end();
}
bool hasRecognizedWaitFor(const Handler& handler) {
for (const auto tag : handler.waitFor) {
const auto dependency = handlers.find(tag);
if (dependency != handlers.end() && dependency->second.recognized) {
return true;
}
}
return false;
}
bool hasUnresolvedWaitFor(const Handler& handler) {
for (const auto tag : handler.waitFor) {
const auto dependency = handlers.find(tag);
if (dependency != handlers.end() && dependency->second.state == kBegan &&
!dependency->second.recognized) {
return true;
}
}
return false;
}
bool blockedByActiveHandler(int32_t handlerTag) {
for (const auto& [otherTag, other] : handlers) {
if (otherTag != handlerTag && other.state == kActive &&
hasTag(other.blocksHandlers, handlerTag)) {
return true;
}
}
return false;
}
std::vector<RecognizedGesture> processPointer(const RawPointerEvent& pointer) {
std::vector<RecognizedGesture> events;
std::lock_guard<std::mutex> lock(handlersMutex);
if (gestureTracingEnabled() && pointer.phase != 4) {
LOG(INFO) << "[gesture-handler] pointer phase=" << pointer.phase << " at ("
<< pointer.x << ',' << pointer.y
<< ") chain=" << pointer.targetChain.size() << " tags";
}
if (pointer.phase == 0) {
for (auto& [_, handler] : handlers) {
if (!enabled(handler) || handler.viewTag < 0 ||
std::find(
pointer.targetChain.begin(),
pointer.targetChain.end(),
handler.viewTag) == pointer.targetChain.end()) {
continue;
}
handler.state = kUndetermined;
handler.startX = pointer.x;
handler.startY = pointer.y;
handler.lastX = pointer.x;
handler.lastY = pointer.y;
handler.startTime = pointer.timestamp;
handler.lastTime = pointer.timestamp;
handler.recognized = false;
appendState(events, _, handler, kBegan, pointer);
if (gestureTracingEnabled()) {
LOG(INFO) << "[gesture-handler] began handler=" << _
<< " name=" << handler.name << " view=" << handler.viewTag;
}
}
return events;
}
if (pointer.phase == 2) {
// Resolve recognizers that cannot succeed on release before testing tap
// handlers that may be waiting for them to fail.
for (auto& [handlerTag, handler] : handlers) {
if (handler.state != kBegan || isTap(handler)) {
continue;
}
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
if (isLongPress(handler)) {
const auto elapsed = pointer.timestamp - handler.startTime;
const auto minDuration =
number(handler.config, "minDurationMs", 500.0f);
if (elapsed >= minDuration &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f) &&
!hasRecognizedWaitFor(handler) &&
!blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kActive, pointer);
} else {
appendState(events, handlerTag, handler, kFailed, pointer);
}
} else {
appendState(events, handlerTag, handler, kFailed, pointer);
}
}
}
for (auto& [handlerTag, handler] : handlers) {
if (handler.state != kBegan && handler.state != kActive)
continue;
if (pointer.phase == 4) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
const auto elapsed = pointer.timestamp - handler.startTime;
const auto minDuration = number(handler.config, "minDurationMs", 500.0f);
if (gestureTracingEnabled() && handler.state == kBegan &&
isLongPress(handler) && elapsed >= minDuration) {
LOG(INFO) << "[gesture-handler] long-press threshold handler="
<< handlerTag << " elapsed=" << elapsed
<< " required=" << minDuration
<< " distance=" << std::hypot(dx, dy);
}
if (handler.state == kBegan && isLongPress(handler) &&
elapsed >= minDuration &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
}
}
} else if (pointer.phase == 1) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
if (handler.state == kBegan && isPan(handler) &&
std::hypot(dx, dy) >= number(handler.config, "minDist", 3.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
}
} else if (
handler.state == kBegan && isLongPress(handler) &&
pointer.timestamp - handler.startTime >=
number(handler.config, "minDurationMs", 500.0f) &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
}
}
if (handler.state == kActive &&
(isPan(handler) || isLongPress(handler))) {
appendEvent(
events,
handler.viewTag,
"topGestureHandlerEvent",
payloadFor(
handlerTag,
handler,
handler.state,
kUndetermined,
pointer,
false));
}
} else if (pointer.phase == 2) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
if (isTap(handler) && handler.state == kBegan &&
pointer.timestamp - handler.startTime <=
number(handler.config, "maxDurationMs", 500.0f) &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
} else {
appendState(events, handlerTag, handler, kFailed, pointer);
}
} else if (
isLongPress(handler) && handler.state == kBegan &&
pointer.timestamp - handler.startTime >=
number(handler.config, "minDurationMs", 500.0f) &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
appendState(events, handlerTag, handler, kActive, pointer);
} else if (handler.state == kBegan) {
appendState(events, handlerTag, handler, kFailed, pointer);
}
if (handler.state == kActive) {
if (isTap(handler) || isLongPress(handler)) {
appendEvent(
events,
handler.viewTag,
"topGestureHandlerEvent",
payloadFor(
handlerTag,
handler,
handler.state,
kUndetermined,
pointer,
false));
}
appendState(events, handlerTag, handler, kEnd, pointer);
}
handler.lastX = pointer.x;
handler.lastY = pointer.y;
handler.lastTime = pointer.timestamp;
} else if (pointer.phase == 3) {
appendState(
events,
handlerTag,
handler,
handler.state == kActive ? kCancelled : kFailed,
pointer);
}
}
if (gestureTracingEnabled() && (pointer.phase != 4 || !events.empty())) {
LOG(INFO) << "[gesture-handler] recognized events=" << events.size();
}
return events;
}
class GPUIGestureHandlerModule final
: public facebook::react::NativeRNGestureHandlerModuleSpec<
GPUIGestureHandlerModule> {
public:
explicit GPUIGestureHandlerModule(
std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
: NativeRNGestureHandlerModuleSpec(std::move(jsInvoker)) {}
// The JS side queues operations and flushes them in order, so every method
// has to observe the same order the JS thread produced.
static constexpr rngpui::ModuleThread kThread = rngpui::ModuleThread::JS;
void createGestureHandler(
Runtime&,
std::string handlerName,
double handlerTag,
folly::dynamic config) {
Handler handler;
handler.name = std::move(handlerName);
handler.config = configObject(std::move(config));
std::lock_guard<std::mutex> lock(handlersMutex);
handlers.insert_or_assign(
static_cast<int32_t>(handlerTag), std::move(handler));
}
void attachGestureHandler(
Runtime&,
double handlerTag,
double viewTag,
double actionType) {
const auto tag = static_cast<int32_t>(handlerTag);
std::lock_guard<std::mutex> lock(handlersMutex);
auto found = handlers.find(tag);
if (found != handlers.end()) {
found->second.viewTag = static_cast<int32_t>(viewTag);
found->second.actionType = static_cast<int32_t>(actionType);
if (gestureTracingEnabled()) {
LOG(INFO) << "[gesture-handler] attached handler=" << tag
<< " view=" << found->second.viewTag
<< " action=" << found->second.actionType;
}
}
}
void setGestureHandlerConfig(
Runtime&,
double handlerTag,
folly::dynamic newConfig) {
const auto tag = static_cast<int32_t>(handlerTag);
auto config = configObject(std::move(newConfig));
std::lock_guard<std::mutex> lock(handlersMutex);
auto found = handlers.find(tag);
if (found != handlers.end())
found->second.config = std::move(config);
}
void updateGestureHandlerConfig(
Runtime&,
double handlerTag,
folly::dynamic newConfig) {
const auto tag = static_cast<int32_t>(handlerTag);
std::lock_guard<std::mutex> lock(handlersMutex);
auto found = handlers.find(tag);
if (found != handlers.end() && newConfig.isObject()) {
for (const auto& [key, value] : newConfig.items())
found->second.config[key] = value;
}
}
void
configureRelations(Runtime&, double handlerTag, folly::dynamic relations) {
const auto tag = static_cast<int32_t>(handlerTag);
std::lock_guard<std::mutex> lock(handlersMutex);
const auto found = handlers.find(tag);
if (found != handlers.end()) {
found->second.simultaneousHandlers =
relationTags(relations, "simultaneousHandlers");
found->second.waitFor = relationTags(relations, "waitFor");
found->second.blocksHandlers = relationTags(relations, "blocksHandlers");
if (gestureTracingEnabled()) {
auto formatTags = [](const std::vector<int32_t>& tags) {
std::string result;
for (const auto value : tags) {
if (!result.empty())
result += ",";
result += std::to_string(value);
}
return result;
};
LOG(INFO) << "[gesture-handler] relations handler=" << tag
<< " name=" << found->second.name << " simultaneous="
<< formatTags(found->second.simultaneousHandlers)
<< " waitFor=" << formatTags(found->second.waitFor)
<< " blocks=" << formatTags(found->second.blocksHandlers);
}
}
}
void dropGestureHandler(Runtime&, double handlerTag) {
std::lock_guard<std::mutex> lock(handlersMutex);
handlers.erase(static_cast<int32_t>(handlerTag));
}
void flushOperations(Runtime&) {}
bool installUIRuntimeBindings(Runtime&) {
return true;
}
};
} // namespace
std::shared_ptr<facebook::react::TurboModule>
create_gpui_gesture_handler_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
if (name != GPUIGestureHandlerModule::kModuleName) {
return nullptr;
}
rngpui::registerPointerObserver(
[](const rngpui::extension::RawPointerEvent& pointer) {
return processPointer(pointer);
});
if (gestureTracingEnabled()) {
LOG(INFO) << "[gesture-handler] native pointer processor installed";
}
return std::make_shared<GPUIGestureHandlerModule>(std::move(jsInvoker));
}
@@ -0,0 +1,22 @@
{
"name": "@react-native-gpui/gesture-handler",
"version": "0.1.0",
"description": "Native GPUI gesture recognition for react-native-gesture-handler",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-gesture-handler": "3.3.x",
"react-native-reanimated": "4.7.x",
"react-native-worklets": "0.13.x"
},
"codegenConfig": {
"name": "GPUIGestureHandlerSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"license": "MIT"
}
@@ -0,0 +1,25 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
cppProvider: 'create_gpui_gesture_handler_module',
componentCodegenPackage: 'react-native-gesture-handler',
componentDescriptors: [
'RNGestureHandlerButtonComponentDescriptor',
'RNGestureHandlerRootViewComponentDescriptor',
],
componentHeaders: [
'react/renderer/components/rngesturehandler_codegen/ComponentDescriptors.h',
],
// Metro: replace these files of installed packages for gpui.
sourceOverrides: {
'react-native-gesture-handler/src/handlers/customDirectEventTypes.ts':
'./src/customDirectEventTypes.desktop.ts',
'react-native-gesture-handler/src/v3/components/GestureButtons.tsx':
'./src/GestureButtons.desktop.tsx',
},
},
},
},
};
@@ -0,0 +1,71 @@
import React from 'react';
import './customDirectEventTypes.desktop';
import {
Pressable,
type ColorValue,
type PressableProps,
type StyleProp,
type ViewStyle,
} from 'react-native';
type DesktopButtonProps = Omit<PressableProps, 'style'> & {
style?: StyleProp<ViewStyle>;
activeOpacity?: number;
underlayColor?: ColorValue;
onActiveStateChange?: (active: boolean) => void;
pressedStyle?: ViewStyle;
borderless?: boolean;
rippleColor?: ColorValue | number | null;
rippleRadius?: number | null;
foreground?: boolean;
exclusive?: boolean;
cancelable?: boolean;
};
function DesktopButton({
activeOpacity = 0.75,
onActiveStateChange,
onPressIn,
onPressOut,
pressedStyle,
style,
...props
}: DesktopButtonProps) {
return (
<Pressable
{...props}
style={({ pressed }) => [
style,
pressed ? [{ opacity: activeOpacity }, pressedStyle] : undefined,
]}
onPressIn={event => {
onActiveStateChange?.(true);
onPressIn?.(event);
}}
onPressOut={event => {
onActiveStateChange?.(false);
onPressOut?.(event);
}}
/>
);
}
export const RectButton = ({ underlayColor, ...props }: DesktopButtonProps) => (
<DesktopButton
{...props}
pressedStyle={
underlayColor == null ? undefined : { backgroundColor: underlayColor }
}
/>
);
export const BorderlessButton = (props: DesktopButtonProps) => (
<DesktopButton {...props} />
);
export const RawButton = DesktopButton;
export const BaseButton = DesktopButton;
export const LegacyRectButton = RectButton;
export const LegacyBorderlessButton = BorderlessButton;
export const LegacyRawButton = RawButton;
export const LegacyBaseButton = BaseButton;
@@ -0,0 +1,32 @@
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
createGestureHandler: (
handlerName: string,
handlerTag: CodegenTypes.Double,
config: Object,
) => void;
attachGestureHandler: (
handlerTag: CodegenTypes.Double,
viewTag: CodegenTypes.Double,
actionType: CodegenTypes.Double,
) => void;
setGestureHandlerConfig: (
handlerTag: CodegenTypes.Double,
newConfig: Object,
) => void;
updateGestureHandlerConfig: (
handlerTag: CodegenTypes.Double,
newConfig: Object,
) => void;
configureRelations: (
handlerTag: CodegenTypes.Double,
relations: Object,
) => void;
dropGestureHandler: (handlerTag: CodegenTypes.Double) => void;
flushOperations: () => void;
installUIRuntimeBindings: () => boolean;
}
export default TurboModuleRegistry.getEnforcing<Spec>('RNGestureHandlerModule');
@@ -0,0 +1,13 @@
// RNGH's legacy handler path registers only the continuous event in its JS
// module. GPUI dispatches the codegen state-change event directly, so expose
// both direct-event registrations from this platform package.
import { customDirectEventTypes } from 'react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
customDirectEventTypes.topGestureHandlerEvent = {
registrationName: 'onGestureHandlerEvent',
};
customDirectEventTypes.topGestureHandlerStateChange = {
registrationName: 'onGestureHandlerStateChange',
};
export { customDirectEventTypes };
@@ -0,0 +1,94 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/reanimated was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
set(_gpui_app_root "${RN_GPUI_PROJECT_ROOT}")
find_program(_gpui_node node REQUIRED)
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('react-native-reanimated/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _reanimated_resolve_result
OUTPUT_VARIABLE _reanimated_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _reanimated_resolve_result EQUAL 0 OR NOT EXISTS "${_reanimated_package_json}")
message(FATAL_ERROR "react-native-reanimated could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_reanimated_root "${_reanimated_package_json}" DIRECTORY)
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('react-native/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _react_native_resolve_result
OUTPUT_VARIABLE _react_native_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _react_native_resolve_result EQUAL 0 OR NOT EXISTS "${_react_native_package_json}")
message(FATAL_ERROR "react-native could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_react_native_root "${_react_native_package_json}" DIRECTORY)
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('react-native-worklets/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _worklets_resolve_result
OUTPUT_VARIABLE _worklets_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _worklets_resolve_result EQUAL 0 OR NOT EXISTS "${_worklets_package_json}")
message(FATAL_ERROR "react-native-worklets could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_worklets_root "${_worklets_package_json}" DIRECTORY)
set(_reanimated_common "${_reanimated_root}/Common/cpp")
set(_worklets_common "${_worklets_root}/Common/cpp")
if(NOT EXISTS "${_reanimated_common}/reanimated/NativeModules/ReanimatedModuleProxy.h")
message(FATAL_ERROR "react-native-reanimated 4.7.x was not found in ${_gpui_app_root} or any ancestor node_modules directory")
endif()
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('@react-native-gpui/worklets/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _gpui_worklets_resolve_result
OUTPUT_VARIABLE _gpui_worklets_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _gpui_worklets_resolve_result EQUAL 0 OR NOT EXISTS "${_gpui_worklets_package_json}")
message(FATAL_ERROR "@react-native-gpui/worklets could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_gpui_worklets_root "${_gpui_worklets_package_json}" DIRECTORY)
if(NOT EXISTS "${_worklets_common}/worklets/Compat/StableApi.h")
message(FATAL_ERROR "react-native-worklets 0.13.x is required by GPUI Reanimated")
endif()
# Match the static feature-flag definition normally supplied by Reanimated's
# CocoaPods/Gradle builds. Without this, FeatureFlags.h silently treats every
# flag as false, including defaults which change how animated props are
# committed through Fabric.
execute_process(
COMMAND "${_gpui_node}" -e
"const fs=require('fs'); const flags=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); const app=JSON.parse(fs.readFileSync(process.argv[2],'utf8')); Object.assign(flags, app.reanimated?.staticFeatureFlags ?? {}); if(process.platform==='darwin' && !Object.hasOwn(app.reanimated?.staticFeatureFlags ?? {}, 'IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS')) flags.IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS=true; if(process.platform==='linux' && !Object.hasOwn(app.reanimated?.staticFeatureFlags ?? {}, 'LINUX_SYNCHRONOUSLY_UPDATE_UI_PROPS')) flags.LINUX_SYNCHRONOUSLY_UPDATE_UI_PROPS=true; process.stdout.write(Object.entries(flags).map(([key,value])=>'['+key+':'+value+']').join(''));"
"${_reanimated_root}/src/featureFlags/staticFlags.json"
"${_gpui_app_root}/package.json"
RESULT_VARIABLE _reanimated_flags_result
OUTPUT_VARIABLE _reanimated_feature_flags
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(NOT _reanimated_flags_result EQUAL 0 OR _reanimated_feature_flags STREQUAL "")
message(FATAL_ERROR "Failed to load Reanimated static feature flags for GPUI")
endif()
execute_process(
COMMAND "${_gpui_node}" "${_react_native_root}/scripts/generate-codegen-artifacts.js"
--path "${_gpui_app_root}"
--targetPlatform ios
--outputPath "${_gpui_app_root}/desktop/build/codegen"
--source app
RESULT_VARIABLE _reanimated_codegen_result
)
if(NOT _reanimated_codegen_result EQUAL 0)
message(FATAL_ERROR "React Native codegen failed while configuring GPUI Reanimated")
endif()
set(_rn_codegen "${_gpui_app_root}/desktop/build/codegen/build/generated/ios/ReactCodegen")
file(GLOB_RECURSE _reanimated_sources CONFIGURE_DEPENDS "${_reanimated_common}/reanimated/*.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES
${_reanimated_sources}
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUIReanimatedModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS
"${_reanimated_common}"
"${_reanimated_root}"
"${_worklets_common}"
"${_rn_codegen}"
"${_gpui_worklets_root}/desktop/cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_DEFINITIONS
"REANIMATED_VERSION=4.7.1"
"REANIMATED_FEATURE_FLAGS=\"${_reanimated_feature_flags}\"")
@@ -0,0 +1,161 @@
#include "GPUIWorkletsModule.h"
#include "GpuiHost.h"
#include <GPUIReanimatedSpec.h>
#include <reanimated/NativeModules/ReanimatedModuleProxy.h>
#include <reanimated/RuntimeDecorators/RNRuntimeDecorator.h>
#include <reanimated/Tools/PlatformDepMethodsHolder.h>
#include <worklets/Compat/StableApi.h>
#include <glog/logging.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <memory>
#include <utility>
namespace {
bool animationTracingEnabled() {
static const bool enabled = std::getenv("RN_GPUI_DEBUG_ANIMATION") != nullptr;
return enabled;
}
using facebook::jsi::Runtime;
using reanimated::PlatformDepMethodsHolder;
using reanimated::ReanimatedModuleProxy;
PlatformDepMethodsHolder platformMethods(
const std::shared_ptr<std::weak_ptr<ReanimatedModuleProxy>>& proxyHolder) {
PlatformDepMethodsHolder methods;
methods.requestRender = [](std::function<void(const double)> callback) {
static std::atomic<uint64_t> requests{0};
const auto count = ++requests;
if (animationTracingEnabled() && (count <= 5 || count % 60 == 0)) {
LOG(INFO) << "[gpui-reanimated] requestRender count=" << count;
}
auto module = gpui_worklets::currentModule();
if (module)
module->requestAnimationFrame(std::move(callback));
};
methods.synchronouslyUpdateUIPropsFunction = [](const int tag,
const folly::dynamic& props) {
rngpui::gpui_synchronously_update_props(tag, props);
};
methods.getAnimationTimestamp = [] {
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
};
methods.registerSensor =
[](int, int, int, std::function<void(double[], int)>) { return 0; };
methods.unregisterSensor = [](int) {};
methods.setGestureStateFunction = [](int, int) {};
methods.maybeFlushUIUpdatesQueueFunction = [proxyHolder] {
if (auto proxy = proxyHolder->lock()) {
proxy->performOperations();
}
};
methods.attachPseudoSelector = [](facebook::react::Tag,
reanimated::PseudoSelector,
std::function<void(bool)> callback) {
if (callback)
callback(false);
};
methods.detachPseudoSelector = [](facebook::react::Tag,
reanimated::PseudoSelector) {};
#if defined(__APPLE__)
methods.forceScreenSnapshotFunction = [](facebook::react::Tag) {
return false;
};
methods.readMountedViewPropsFunction =
[](facebook::react::Tag) -> facebook::react::Props::Shared {
return nullptr;
};
#endif
return methods;
}
class Module final
: public facebook::react::NativeReanimatedModuleSpec<Module> {
public:
explicit Module(std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
: NativeReanimatedModuleSpec(std::move(jsInvoker)) {}
bool installTurboModule(Runtime& runtime) {
if (proxy_)
return true;
auto worklets = gpui_worklets::currentModule();
auto uiManager = rngpui::get_gpui_ui_manager();
if (!worklets || !worklets->uiRuntime() || !worklets->uiScheduler() ||
!uiManager) {
throw facebook::jsi::JSError(
runtime, "GPUI Reanimated requires initialized Worklets and Fabric");
}
// The platform flush callback is constructed before the proxy exists.
// Retain only a weak reference so the decorated UI runtime cannot keep
// the module alive after React Native tears it down.
auto proxyHolder = std::make_shared<std::weak_ptr<ReanimatedModuleProxy>>();
auto methods = platformMethods(proxyHolder);
proxy_ = std::make_shared<ReanimatedModuleProxy>(
worklets->uiRuntime(),
worklets->uiScheduler(),
runtime,
jsInvoker_,
methods,
false);
*proxyHolder = proxy_;
proxy_->init(methods);
proxy_->initializeFabric(uiManager);
LOG(INFO) << "[gpui-reanimated] module proxy initialized";
auto& uiRuntime =
worklets::getJSIRuntimeFromWorkletRuntime(worklets->uiRuntime());
reanimated::RNRuntimeDecorator::decorate(runtime, uiRuntime, proxy_);
std::weak_ptr<ReanimatedModuleProxy> weakProxy = proxy_;
eventListener_ = std::make_shared<const facebook::react::EventListener>(
[weakProxy](const facebook::react::RawEvent& event) {
auto proxy = weakProxy.lock();
if (!proxy)
return false;
const auto timestamp =
std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
static std::atomic<uint64_t> events{0};
const auto count = ++events;
if (animationTracingEnabled() &&
event.type.find("GestureHandler") != std::string::npos) {
const auto* target = event.eventTarget.get();
const auto tag = target ? target->getTag() : -1;
auto eventName = event.type;
if (eventName.rfind("top", 0) == 0) {
eventName = "on" + eventName.substr(3);
}
LOG(INFO) << "[gpui-reanimated] gesture lookup type=" << eventName
<< " tag=" << tag << " waiting="
<< proxy->isAnyHandlerWaitingForEvent(eventName, tag);
}
if (animationTracingEnabled() &&
(count <= 10 ||
event.type.find("GestureHandler") != std::string::npos)) {
LOG(INFO) << "[gpui-reanimated] raw event count=" << count
<< " type=" << event.type;
}
return proxy->handleRawEvent(event, timestamp);
});
rngpui::register_gpui_event_listener(eventListener_);
return true;
}
std::shared_ptr<ReanimatedModuleProxy> proxy_;
std::shared_ptr<const facebook::react::EventListener> eventListener_;
};
} // namespace
std::shared_ptr<facebook::react::TurboModule> create_gpui_reanimated_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
return rngpui::provideTurboModule<Module>(name, std::move(jsInvoker));
}
@@ -0,0 +1,22 @@
{
"name": "@react-native-gpui/reanimated",
"version": "0.1.0",
"description": "GPUI platform bindings for Reanimated 4",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-reanimated": "4.7.x",
"react-native-worklets": "0.13.x",
"@react-native-gpui/worklets": "0.1.x"
},
"codegenConfig": {
"name": "GPUIReanimatedSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"license": "MIT"
}
@@ -0,0 +1,10 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
cppProvider: 'create_gpui_reanimated_module',
},
},
},
};
@@ -0,0 +1,8 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
installTurboModule: () => boolean;
}
export default TurboModuleRegistry.get<Spec>('ReanimatedModule');
@@ -0,0 +1,33 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/safe-area-context was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
rn_gpui_find_node_module(react-native-safe-area-context _safe_area_root)
set(_safe_area_common "${_safe_area_root}/common/cpp")
set(_safe_area_shadow_node "${_safe_area_common}/react/renderer/components/safeareacontext/RNCSafeAreaViewShadowNode.cpp")
set(_safe_area_package_json "${_safe_area_root}/package.json")
if(NOT EXISTS "${_safe_area_shadow_node}" OR NOT EXISTS "${_safe_area_package_json}")
message(FATAL_ERROR "react-native-safe-area-context 5.10.x was not found at ${_safe_area_root}")
endif()
file(READ "${_safe_area_package_json}" _safe_area_package_json_contents)
string(JSON _safe_area_version GET "${_safe_area_package_json_contents}" version)
if(NOT _safe_area_version MATCHES "^5\\.10\\.")
message(FATAL_ERROR "@react-native-gpui/safe-area-context supports react-native-safe-area-context 5.10.x, found ${_safe_area_version}")
endif()
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES
"${_safe_area_shadow_node}"
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUISafeAreaContextModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS
"${_safe_area_common}"
"${RN_GPUI_REACT_CXX_PLATFORM_DIR}"
"${CMAKE_CURRENT_LIST_DIR}/cpp")
# Safe-area insets reach the mounting manager through this glue, which the
# platform compiles only because the package is installed.
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GpuiSafeAreaModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INSTALLERS safe_area)
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INCLUDE_DIRS
"${CMAKE_CURRENT_LIST_DIR}/cpp"
"${RN_GPUI_PROJECT_ROOT}/desktop/autolink/codegen/safeareacontext")
@@ -0,0 +1,48 @@
#include <GPUISafeAreaContextSpec.h>
#include <react/coremodules/DeviceInfoModule.h>
#include "GPUISafeAreaInsets.h"
#include <utility>
namespace {
using facebook::react::TurboModule;
class SafeAreaContextModule final
: public facebook::react::NativeSafeAreaContextSpec<SafeAreaContextModule> {
public:
explicit SafeAreaContextModule(
std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
: NativeSafeAreaContextSpec(std::move(jsInvoker)) {}
facebook::jsi::Object getConstants(facebook::jsi::Runtime& runtime) {
const auto windowSize = facebook::react::DeviceInfoModule::windowSize();
facebook::jsi::Object insets(runtime);
const auto safeInsets = gpui_safe_area::gpui_safe_area_insets(0);
insets.setProperty(runtime, "top", safeInsets.top);
insets.setProperty(runtime, "right", safeInsets.right);
insets.setProperty(runtime, "bottom", safeInsets.bottom);
insets.setProperty(runtime, "left", safeInsets.left);
facebook::jsi::Object frame(runtime);
frame.setProperty(runtime, "x", 0.0);
frame.setProperty(runtime, "y", 0.0);
frame.setProperty(runtime, "width", windowSize.width);
frame.setProperty(runtime, "height", windowSize.height);
facebook::jsi::Object metrics(runtime);
metrics.setProperty(runtime, "insets", std::move(insets));
metrics.setProperty(runtime, "frame", std::move(frame));
facebook::jsi::Object constants(runtime);
constants.setProperty(runtime, "initialWindowMetrics", std::move(metrics));
return constants;
}
};
} // namespace
std::shared_ptr<TurboModule> create_gpui_safe_area_context_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
return rngpui::provideTurboModule<SafeAreaContextModule>(
name, std::move(jsInvoker));
}
@@ -0,0 +1,22 @@
#pragma once
#include <atomic>
#include <cstdint>
namespace gpui_safe_area {
struct Insets {
double top;
double right;
double bottom;
double left;
};
inline std::atomic<double> windowTopInset{0.0};
inline Insets gpui_safe_area_insets(int32_t /*surfaceId*/) {
return {
.top = windowTopInset.load(), .right = 0.0, .bottom = 0.0, .left = 0.0};
}
} // namespace gpui_safe_area
@@ -0,0 +1,143 @@
#include "GPUISafeAreaInsets.h"
#include "GpuiModules.h"
#include <react/renderer/components/safeareacontext/RNCSafeAreaViewState.h>
#include <react/renderer/core/ConcreteState.h>
#include <folly/json.h>
#include <glog/logging.h>
#include <array>
#include <mutex>
#include <string_view>
#include <unordered_map>
namespace rngpui {
using namespace facebook::react;
namespace {
struct TrackedView {
std::array<float, 4> frame;
SharedEventEmitter emitter;
std::shared_ptr<const ConcreteState<RNCSafeAreaViewState>> state;
};
std::mutex trackedMutex;
std::unordered_map<Tag, TrackedView> tracked;
std::shared_ptr<const ConcreteState<RNCSafeAreaViewState>> safeAreaState(
const ShadowView& view) {
if (std::string_view(view.componentName) != "RNCSafeAreaView")
return nullptr;
return std::dynamic_pointer_cast<const ConcreteState<RNCSafeAreaViewState>>(
view.state);
}
void dispatchInsets(
const SharedEventEmitter& emitter,
const std::array<float, 4>& frame,
float top,
float right,
float bottom,
float left) {
if (!emitter)
return;
emitter->dispatchEvent(
"insetsChange",
folly::dynamic::object(
"insets",
folly::dynamic::object("top", top)("right", right)("bottom", bottom)(
"left", left))(
"frame",
folly::dynamic::object("x", frame[0])("y", frame[1])(
"width", frame[2])("height", frame[3])));
}
void applyInsets(float top, float right, float bottom, float left) {
const bool changed = gpui_safe_area::windowTopInset.exchange(top) != top;
std::vector<std::shared_ptr<const ConcreteState<RNCSafeAreaViewState>>>
states;
{
std::lock_guard<std::mutex> lock(trackedMutex);
for (const auto& [tag, entry] : tracked) {
(void)tag;
if (changed && entry.emitter)
dispatchInsets(entry.emitter, entry.frame, top, right, bottom, left);
if (!entry.state)
continue;
// The tracked state is the one the view had when its frame last changed.
// A state update does not move the frame, so it would stay at the old
// insets, and every batch would send the same update again: one commit
// per batch, for ever.
const auto latest =
std::dynamic_pointer_cast<const ConcreteState<RNCSafeAreaViewState>>(
entry.state->getMostRecentState());
const auto& current = latest ? latest : entry.state;
if (current->getData().insets.top != top)
states.push_back(current);
}
}
for (const auto& state : states) {
auto next = state->getData();
next.insets.top = top;
state->updateState(std::move(next));
}
}
} // namespace
void rn_gpui_install_safe_area_module() {
registerMountObserver(extension::MountObserver{
.onFrameChanged =
[](SurfaceId surfaceId, const ShadowView& view) {
const auto& frame = view.layoutMetrics.frame;
const std::array<float, 4> nextFrame = {
frame.origin.x,
frame.origin.y,
frame.size.width,
frame.size.height};
const bool isProvider =
std::string_view(view.componentName) == "RNCSafeAreaProvider";
if (!isProvider && !safeAreaState(view))
return;
{
std::lock_guard<std::mutex> lock(trackedMutex);
auto& entry = tracked[view.tag];
if (isProvider && entry.emitter && entry.frame == nextFrame)
return;
entry.frame = nextFrame;
entry.emitter = view.eventEmitter;
entry.state = safeAreaState(view);
}
if (!isProvider)
return;
const auto insets = gpui_safe_area::gpui_safe_area_insets(
static_cast<int32_t>(surfaceId));
LOG(INFO) << "[gpui-safe-area] onInsetsChange tag=" << view.tag
<< " surface=" << surfaceId << " frame=("
<< frame.origin.x << ',' << frame.origin.y << ' '
<< frame.size.width << 'x' << frame.size.height << ')';
dispatchInsets(
view.eventEmitter,
nextFrame,
insets.top,
insets.right,
insets.bottom,
insets.left);
},
.onDeleted =
[](Tag tag) {
std::lock_guard<std::mutex> lock(trackedMutex);
tracked.erase(tag);
},
.afterBatch =
[] {
applyInsets(
gpui_safe_area::windowTopInset.load(), 0.0f, 0.0f, 0.0f);
}});
registerWindowInsetsObserver(
[](float top, float right, float bottom, float left) {
applyInsets(top, right, bottom, left);
});
}
} // namespace rngpui
@@ -0,0 +1,20 @@
{
"name": "@react-native-gpui/safe-area-context",
"version": "0.1.0",
"description": "GPUI platform implementation for react-native-safe-area-context",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-safe-area-context": "5.10.x"
},
"codegenConfig": {
"name": "GPUISafeAreaContextSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"license": "MIT"
}
@@ -0,0 +1,19 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
cppProvider: 'create_gpui_safe_area_context_module',
componentCodegenPackage: 'react-native-safe-area-context',
componentDescriptors: [
'RNCSafeAreaProviderComponentDescriptor',
'RNCSafeAreaViewComponentDescriptor',
],
componentHeaders: [
'react/renderer/components/safeareacontext/ComponentDescriptors.h',
'react/renderer/components/safeareacontext/RNCSafeAreaViewComponentDescriptor.h',
],
},
},
},
};
@@ -0,0 +1,23 @@
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getConstants: () => {
initialWindowMetrics?: {
insets: {
top: CodegenTypes.Double;
right: CodegenTypes.Double;
bottom: CodegenTypes.Double;
left: CodegenTypes.Double;
};
frame: {
x: CodegenTypes.Double;
y: CodegenTypes.Double;
width: CodegenTypes.Double;
height: CodegenTypes.Double;
};
};
};
}
export default TurboModuleRegistry.get<Spec>('RNCSafeAreaContext');
@@ -0,0 +1,30 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/screens was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
rn_gpui_find_node_module(react-native-screens _screens_root)
set(_screens_common "${_screens_root}/common/cpp")
set(_screens_component_dir "${_screens_common}/react/renderer/components/rnscreens")
set(_screens_package_json "${_screens_root}/package.json")
if(NOT EXISTS "${_screens_component_dir}/RNSScreenShadowNode.cpp" OR NOT EXISTS "${_screens_package_json}")
message(FATAL_ERROR "react-native-screens 4.28.x was not found at ${_screens_root}")
endif()
file(READ "${_screens_package_json}" _screens_package_json_contents)
string(JSON _screens_version GET "${_screens_package_json_contents}" version)
if(NOT _screens_version MATCHES "^4\\.28\\.")
message(FATAL_ERROR "@react-native-gpui/screens supports react-native-screens 4.28.x, found ${_screens_version}")
endif()
file(GLOB _screens_common_sources CONFIGURE_DEPENDS "${_screens_component_dir}/*.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES ${_screens_common_sources})
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS "${_screens_common}")
# The mount pipeline knows nothing about screens: this glue fills the header and
# screen fields of a mount op, and the platform compiles it only because the
# package is installed.
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GpuiScreensModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INSTALLERS screens)
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INCLUDE_DIRS
"${_screens_common}")
@@ -0,0 +1,54 @@
[package]
name = "rn-gpui-screens"
version = "0.1.0"
edition = "2021"
[dependencies]
react-native-gpui = "0.1"
rn-gpui-sys = "0.1"
gpui = "0.2"
serde_json = "1"
log = "0.4"
[dev-dependencies]
# `TestNode` and `TreeView::for_test` are gated behind `test-driver`, and a
# dependency cannot enable another package's feature. It is a dev-dependency
# rather than a normal one so a build of the module never pays for it, and it only
# becomes usable because this crate is its own workspace root below.
react-native-gpui = { version = "0.1", features = ["test-driver"] }
# Its own workspace root, deliberately. Cargo refuses to test a package that has
# dev-dependencies unless it is a workspace member, and core's workspace cannot
# hold it: a module resolves core by version and must not be a member of the
# checkout's workspace. A root with itself as the only member satisfies the rule
# without making the module part of anything else.
[workspace]
# The checkout's shared dev profile (see core's `ReactGpui/Cargo.toml`): line tables
# only, no debug info for dependencies, which are optimized. Every workspace root
# builds into the one root `target/`, so a matching profile is also what lets them
# reuse each other's dependency builds instead of storing one copy each.
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "packed"
[profile.dev.package."*"]
debug = false
opt-level = 3
[profile.dev.package.rn-gpui-sys]
opt-level = 0
# This block has to live here because Cargo honours `[patch.crates-io]` only at a
# workspace root, and this crate is one. `npm run test:rust` regenerates it from
# core's manifest before running these tests and fails if it changed, so the gpui
# revision is never written down by hand — commit whatever the run rewrites.
[patch.crates-io]
# Managed by @react-native-gpui/cli autolink; do not edit between these markers.
react-native-gpui = { path = "../../../packages/core/ReactGpui/rn-gpui" }
rn-gpui-sys = { path = "../../../packages/core/ReactGpui/rn-gpui-sys" }
rn-gpui-module = { path = "../../../packages/core/ReactGpui/rn-gpui-module" }
rn-gpui-module-build = { path = "../../../packages/core/ReactGpui/rn-gpui-module-build" }
gpui = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
gpui_platform = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
# End managed by autolink.
@@ -0,0 +1,38 @@
//! Puts the C++ runtime's shared libraries on this crate's test binary rpath.
//!
//! `rn-gpui-screens`'s tests build gpui trees, which link `libjsi.dylib` and the
//! rest of the Node-API runtime. Core's `build.rs` emits
//! `cargo:rustc-link-arg=-Wl,-rpath,<dir>` for its own binary — the app — and that
//! flag is not inherited by a dependency's test binary, so the test process
//! aborted in dyld with `Library not loaded: @rpath/libjsi.dylib` before running a
//! single test.
//!
//! Core exports the directories it was given as `DEP_RN_GPUI_CORE_RPATHS`, which
//! is the same variable core's own `build.rs` reads. Re-emitting it here scopes
//! the flag to this crate's test binary, since `cargo:rustc-link-arg` without a
//! target suffix applies to the crate's own targets and not to the app that
//! depends on it.
fn main() {
println!("cargo:rerun-if-env-changed=DEP_RN_GPUI_CORE_RPATHS");
println!("cargo:rerun-if-changed=build.rs");
let Ok(dirs) = std::env::var("DEP_RN_GPUI_CORE_RPATHS") else {
return;
};
let dirs = dirs
.split('|')
.filter(|dir| !dir.is_empty())
.collect::<Vec<_>>();
if dirs.is_empty() {
return;
}
// MSVC and MinGW have no rpath, and the flag is rejected outright; Windows
// finds the DLLs through PATH, which the test runner already arranges.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
return;
}
for dir in dirs {
println!("cargo:rustc-link-arg=-Wl,-rpath,{dir}");
}
}
@@ -0,0 +1,77 @@
#include "GpuiModules.h"
#include <folly/json.h>
#include <react/renderer/components/rnscreens/Props.h>
#include <string>
namespace rngpui {
namespace {
using namespace facebook::react;
std::string screenPropsJson(
const std::string& /*componentName*/,
const ShadowView& view) {
if (!view.props) {
return std::string("{}");
}
auto props = std::dynamic_pointer_cast<const RNSScreenProps>(view.props);
if (!props) {
return std::string("{}");
}
folly::dynamic object = folly::dynamic::object;
object["activityState"] = props->activityState;
object["stackAnimation"] = toString(props->stackAnimation);
object["stackPresentation"] = toString(props->stackPresentation);
return folly::toJson(object);
}
std::string headerConfigPropsJson(
const std::string& /*componentName*/,
const ShadowView& view) {
if (!view.props) {
return std::string("{}");
}
auto props = std::dynamic_pointer_cast<const RNSScreenStackHeaderConfigProps>(
view.props);
if (!props) {
return std::string("{}");
}
folly::dynamic object = folly::dynamic::object;
object["title"] = props->title;
object["backgroundColor"] = argb(props->backgroundColor);
object["titleColor"] = argb(props->titleColor);
object["tintColor"] = argb(props->color);
object["hidden"] = props->hidden;
object["hideBackButton"] = props->hideBackButton;
return folly::toJson(object);
}
std::string headerSubviewPropsJson(
const std::string& /*componentName*/,
const ShadowView& view) {
if (!view.props) {
return std::string("{}");
}
auto props =
std::dynamic_pointer_cast<const RNSScreenStackHeaderSubviewProps>(
view.props);
if (!props) {
return std::string("{}");
}
folly::dynamic object = folly::dynamic::object;
object["type"] = toString(props->type);
return folly::toJson(object);
}
} // namespace
void rn_gpui_install_screens_module() {
registerComponentProps("RNSScreen", screenPropsJson);
registerComponentProps("RNSScreenStackHeaderConfig", headerConfigPropsJson);
registerComponentProps("RNSScreenStackHeaderSubview", headerSubviewPropsJson);
}
} // namespace rngpui
@@ -0,0 +1,29 @@
//! The screens module.
//!
//! Everything react-native-screens needs lives here: its props extraction in
//! `cpp/`, and its layout, painting and lifecycle in the modules below. Core is
//! told only what any library can say — a component name, a props reader, and a
//! set of generic hooks.
pub mod lifecycle;
pub mod props;
pub mod render;
pub mod stack;
pub mod state;
pub mod transition;
pub mod unsupported;
/// Registers this module's renderers and observer.
///
/// Autolink calls this from `ensure_linked()` while `main` starts, before any
/// `gpui::App` exists, so nothing here may touch the app.
pub fn rn_gpui_register() {
react_native_gpui::extensions::register_store_observer(std::sync::Arc::new(
stack::StackObserver,
));
for (component, build) in render::RENDERED_COMPONENTS {
react_native_gpui::extensions::register_component_renderer(
*component,
std::sync::Arc::from(build()),
);
}
}
@@ -0,0 +1,80 @@
//! The lifecycle events a stack sends to its screens as they appear and leave.
//!
//! React Native exposes these as `topWillAppear`, `topAppear`,
//! `topWillDisappear` and `topDisappear`, in that order around a transition.
use gpui::App;
use react_native_gpui::extensions::StoreHandle;
/// The events an activity-state change produces, in the order they fire.
pub fn lifecycle_events(previous: f32, next: f32) -> &'static [&'static str] {
match (previous < 1.0, previous >= 2.0, next < 1.0, next >= 2.0) {
(true, _, false, true) => &["topWillAppear", "topAppear"],
(true, _, false, false) => &["topWillAppear"],
(_, true, false, false) => &["topWillDisappear"],
(false, false, true, _) => &["topDisappear"],
(false, true, true, _) => &["topWillDisappear", "topDisappear"],
(false, false, false, true) => &["topAppear"],
_ => &[],
}
}
/// Sends the "will" half of a transition: the outgoing screen then the incoming.
pub fn dispatch_will(
handle: &StoreHandle,
outgoing: Option<i32>,
incoming: Option<i32>,
cx: &mut App,
) {
for (tag, event) in [
outgoing.map(|tag| (tag, "topWillDisappear")),
incoming.map(|tag| (tag, "topWillAppear")),
]
.into_iter()
.flatten()
{
log::info!("[screens] tag={tag} lifecycle={event}");
handle.dispatch_event(tag, event, &serde_json::json!({}), cx);
}
}
/// Sends the "did" half: the outgoing screen then the incoming, and wakes
/// Fabric so the callbacks arrive before a later pop unmounts them.
pub fn dispatch_did(
handle: &StoreHandle,
outgoing: Option<i32>,
incoming: Option<i32>,
cx: &mut App,
) {
for (tag, event) in [
outgoing.map(|tag| (tag, "topDisappear")),
incoming.map(|tag| (tag, "topAppear")),
]
.into_iter()
.flatten()
{
log::info!("[screens] tag={tag} lifecycle={event}");
handle.dispatch_event(tag, event, &serde_json::json!({}), cx);
}
// A timer can complete after the display-frame chain has gone idle, so the
// JavaScript that handles these events needs a render to arrive in.
handle.wake(cx);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activity_state_edges_emit_lifecycle_in_order() {
assert_eq!(lifecycle_events(0.0, 1.0), &["topWillAppear"]);
assert_eq!(lifecycle_events(1.0, 2.0), &["topAppear"]);
assert_eq!(lifecycle_events(2.0, 1.0), &["topWillDisappear"]);
assert_eq!(lifecycle_events(1.0, 0.0), &["topDisappear"]);
assert_eq!(lifecycle_events(0.0, 2.0), &["topWillAppear", "topAppear"]);
assert_eq!(
lifecycle_events(2.0, 0.0),
&["topWillDisappear", "topDisappear"]
);
assert!(lifecycle_events(1.0, 1.0).is_empty());
}
}
@@ -0,0 +1,177 @@
//! Typed readers over the JSON this module's C++ half produced.
//!
//! A missing key reads as today's zero value, so a component that has not been
//! given the prop yet behaves the way it did before it arrived.
use react_native_gpui::extensions::ComponentNode;
/// A number, or 0 when absent.
fn number(node: &ComponentNode, key: &str) -> f64 {
node.props
.get(key)
.and_then(|value| value.as_f64())
.unwrap_or(0.0)
}
/// A number as `f32`, or 0 when absent.
fn number_f32(node: &ComponentNode, key: &str) -> f32 {
number(node, key) as f32
}
/// A string, borrowed from the props.
fn string<'a>(node: &'a ComponentNode, key: &str) -> &'a str {
node.props
.get(key)
.and_then(|value| value.as_str())
.unwrap_or("")
}
/// A boolean, false when absent.
fn boolean(node: &ComponentNode, key: &str) -> bool {
node.props
.get(key)
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
/// A colour as 0xAARRGGBB, 0 when unset.
fn color(node: &ComponentNode, key: &str) -> u32 {
number(node, key) as u32
}
/// An `RNSScreen`'s activity state. 0 is detached, 1 is transitioning, 2 is
/// fully on screen.
pub fn activity_state(node: &ComponentNode) -> f32 {
number_f32(node, "activityState")
}
/// An `RNSScreen`'s `stackAnimation`.
pub fn stack_animation(node: &ComponentNode) -> &str {
string(node, "stackAnimation")
}
/// An `RNSScreen`'s `stackPresentation`.
pub fn stack_presentation(node: &ComponentNode) -> &str {
string(node, "stackPresentation")
}
/// Whether a presentation paints its own card over the one underneath.
pub fn is_modal_presentation(node: &ComponentNode) -> bool {
matches!(
stack_presentation(node),
"modal" | "transparentModal" | "containedModal" | "formSheet"
)
}
/// Whether that card lets the screen underneath show through.
pub fn is_transparent_presentation(node: &ComponentNode) -> bool {
stack_presentation(node) == "transparentModal"
}
/// A header config's title.
pub fn header_title(node: &ComponentNode) -> &str {
string(node, "title")
}
/// A header config's background colour.
pub fn header_background_color(node: &ComponentNode) -> u32 {
color(node, "backgroundColor")
}
/// A header config's title colour.
pub fn header_title_color(node: &ComponentNode) -> u32 {
color(node, "titleColor")
}
/// A header config's tint colour, used for the back button.
pub fn header_tint_color(node: &ComponentNode) -> u32 {
color(node, "tintColor")
}
/// Whether a header config hides the whole bar.
pub fn header_hidden(node: &ComponentNode) -> bool {
boolean(node, "hidden")
}
/// Whether a header config hides only the back button.
pub fn header_hide_back(node: &ComponentNode) -> bool {
boolean(node, "hideBackButton")
}
/// A header subview's placement: `left`, `right`, `center`, `title` or a Yoga x.
pub fn subview_type(node: &ComponentNode) -> &str {
string(node, "type")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn node(json: serde_json::Value) -> ComponentNode {
ComponentNode {
component: "RNSScreen".to_string(),
tag: 1,
frame: rn_gpui_sys::ffi::RectF::default(),
scale: (1.0, 1.0),
props: Arc::new(json),
parent: None,
children: Vec::new(),
}
}
#[test]
fn a_missing_key_reads_as_the_zero_value() {
let empty = node(serde_json::json!({}));
assert_eq!(activity_state(&empty), 0.0);
assert_eq!(stack_animation(&empty), "");
assert_eq!(stack_presentation(&empty), "");
assert_eq!(header_title(&empty), "");
assert_eq!(header_background_color(&empty), 0);
assert!(!header_hidden(&empty));
assert!(!header_hide_back(&empty));
assert!(!is_modal_presentation(&empty));
}
#[test]
fn reads_the_fields_the_props_half_writes() {
let screen = node(serde_json::json!({
"activityState": 2,
"stackAnimation": "slide_from_right",
"stackPresentation": "transparentModal",
}));
assert_eq!(activity_state(&screen), 2.0);
assert_eq!(stack_animation(&screen), "slide_from_right");
assert!(is_modal_presentation(&screen));
assert!(is_transparent_presentation(&screen));
let header = node(serde_json::json!({
"title": "Home",
"backgroundColor": 0xff112233u32,
"titleColor": 0xffaabbccu32,
"tintColor": 0xff445566u32,
"hidden": true,
"hideBackButton": true,
}));
assert_eq!(header_title(&header), "Home");
assert_eq!(header_background_color(&header), 0xff112233);
assert_eq!(header_title_color(&header), 0xffaabbcc);
assert_eq!(header_tint_color(&header), 0xff445566);
assert!(header_hidden(&header));
assert!(header_hide_back(&header));
let subview = node(serde_json::json!({"type": "right"}));
assert_eq!(subview_type(&subview), "right");
}
#[test]
fn only_the_modal_presentations_count_as_modal() {
for presentation in ["modal", "transparentModal", "containedModal", "formSheet"] {
let node = node(serde_json::json!({"stackPresentation": presentation}));
assert!(is_modal_presentation(&node), "{presentation}");
}
for presentation in ["push", "card", ""] {
let node = node(serde_json::json!({"stackPresentation": presentation}));
assert!(!is_modal_presentation(&node), "{presentation}");
}
}
}
@@ -0,0 +1,829 @@
//! Painting react-native-screens through the generic renderer hooks.
//!
//! Core builds a box, a background and an accessibility node for every
//! component. What is screens-specific — which screen is on top, where a header
//! subview sits, whether a card is showing — is decided here, from the props this
//! module's C++ half produced and a read-only view of the tree.
use crate::props;
use crate::stack::{
CONTENT_WRAPPER, FULL_WINDOW_OVERLAY, HEADER_CONFIG, HEADER_SUBVIEW, MODAL_SCREEN, SCREEN,
STACK,
};
use gpui::{div, prelude::*, AnyElement, App, MouseButton, SharedString, Window};
use react_native_gpui::extensions::{
store_handle, ComponentNode, ComponentRenderer, Decoration, Portal, StoreHandle, TreeView,
};
use rn_gpui_sys::ffi::RectF;
/// The header bar's height, which the content wrapper is inset by.
pub const HEADER_HEIGHT: f32 = 56.0;
/// A screen inside a stack.
pub struct ScreenRenderer;
impl ComponentRenderer for ScreenRenderer {
fn render(
&self,
_node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
None
}
fn children(
&self,
node: &ComponentNode,
children: Vec<AnyElement>,
_window: &mut Window,
_cx: &mut App,
) -> Vec<AnyElement> {
if !props::is_modal_presentation(node) {
return children;
}
let material = props::is_transparent_presentation(node);
// The frame is in unscaled coordinates and the element this returns is
// painted scaled, so the sheet has to be scaled to land where the card
// it covers does. The old code did the same with `scale_x`/`scale_y`.
let (scale_x, scale_y) = node.scale;
let width = (node.frame.w - SHEET_MARGIN).clamp(0.0, SHEET_MAX_WIDTH) * scale_x;
let height = (node.frame.h - SHEET_MARGIN).clamp(0.0, SHEET_MAX_HEIGHT) * scale_y;
let left = ((node.frame.w * scale_x - width) * 0.5).max(0.0);
let top = ((node.frame.h * scale_y - height) * 0.5).max(0.0);
let mut layers = Vec::with_capacity(2);
if !material {
layers.push(
div()
.absolute()
.inset_0()
.size_full()
.bg(argb(0x80000000))
.into_any_element(),
);
}
layers.push(
div()
.absolute()
.left(gpui::px(left))
.top(gpui::px(top))
.w(gpui::px(width))
.h(gpui::px(height))
.overflow_hidden()
.bg(argb(if material { 0x00000000 } else { 0xff171923 }))
.children(children)
.into_any_element(),
);
layers
}
fn hides_subtree(&self, node: &ComponentNode, tree: &TreeView) -> bool {
if props::activity_state(node) > 0.0 {
return false;
}
if tree
.transition(node.tag)
.is_some_and(|state| state.progress < 1.0)
{
return false;
}
!covered_by_modal_sibling(node, tree)
}
fn decorate(&self, node: &ComponentNode, tree: &TreeView) -> Decoration {
if props::activity_state(node) > 0.0 {
return Decoration::default();
}
let transitioning = tree
.transition(node.tag)
.is_some_and(|state| state.progress < 1.0);
let visible_under_modal = covered_by_modal_sibling(node, tree);
if !transitioning && !visible_under_modal {
return Decoration {
display: Some(false),
..Decoration::default()
};
}
Decoration::default()
}
}
/// Whether a later sibling in the same stack is an active modal presentation.
fn covered_by_modal_sibling(node: &ComponentNode, tree: &TreeView) -> bool {
let Some(parent) = node.parent else {
return false;
};
let siblings = tree.children(parent);
let Some(position) = siblings.iter().position(|tag| *tag == node.tag) else {
return false;
};
siblings
.into_iter()
.skip(position + 1)
.filter_map(|tag| tree.node(tag))
.any(|sibling| {
sibling.component == SCREEN
&& props::activity_state(&sibling) > 0.0
&& props::is_modal_presentation(&sibling)
})
}
/// The stack itself: it clips, and it owns the Escape gesture.
pub struct StackRenderer;
impl ComponentRenderer for StackRenderer {
fn render(
&self,
_node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
None
}
fn decorate(&self, _node: &ComponentNode, _tree: &TreeView) -> Decoration {
Decoration {
clip: true,
..Decoration::default()
}
}
fn handles_keys(&self, _node: &ComponentNode) -> bool {
true
}
fn on_key_down(
&self,
node: &ComponentNode,
key: &str,
tree: &TreeView,
handle: &react_native_gpui::extensions::StoreHandle,
window: &mut Window,
cx: &mut App,
) -> bool {
if key != "escape" {
return false;
}
if has_screen_ancestor(node, tree) {
return false;
}
let screens: Vec<i32> = tree
.children(node.tag)
.into_iter()
.filter(|tag| {
tree.is_live(*tag) && tree.node(*tag).is_some_and(|n| n.component == SCREEN)
})
.collect();
let Some(top) = screens.last().copied() else {
return false;
};
if crate::state::with(|state| state.native_dismiss_pending.is_some()) {
return true;
}
dismiss_top(top, node.tag, tree, handle, window, cx);
true
}
}
/// Whether any ancestor of `node` is a screen, i.e. this stack is nested.
fn has_screen_ancestor(node: &ComponentNode, tree: &TreeView) -> bool {
tree.ancestors(node.tag)
.into_iter()
.filter_map(|tag| tree.node(tag))
.any(|ancestor| ancestor.component == SCREEN)
}
/// The header bar: a background, a title and a back button.
pub struct HeaderConfigRenderer;
impl ComponentRenderer for HeaderConfigRenderer {
fn render(
&self,
_node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
None
}
fn layout_frame(&self, node: &ComponentNode, tree: &TreeView) -> Option<RectF> {
let mut frame = node.frame;
frame.y += tree.window_top_inset();
frame.h = if props::header_hidden(node) || !is_top_header(node, tree) {
0.0
} else {
HEADER_HEIGHT
};
Some(frame)
}
fn hides_subtree(&self, node: &ComponentNode, tree: &TreeView) -> bool {
props::header_hidden(node) || !is_top_header(node, tree)
}
fn decorate(&self, node: &ComponentNode, tree: &TreeView) -> Decoration {
let top = is_top_header(node, tree);
let shown = should_show_back(node, tree);
crate::state::set_header_facts(
node.tag,
top,
shown,
has_title_subview(node, tree),
node.parent,
);
if props::header_hidden(node) || !top {
return Decoration {
display: Some(false),
..Decoration::default()
};
}
let background = first_nonzero(props::header_background_color(node), 0xff171923);
Decoration {
background: Some(background),
..Decoration::default()
}
}
fn depends_on_window_insets(&self) -> bool {
true
}
fn content(
&self,
node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
if props::header_hidden(node) || !crate::state::header_is_top(node.tag) {
return None;
}
// React Native declares `onHeaderBackButtonClicked` on the screen, so
// the config's parent is the tag that has a listener for it.
let Some(screen_tag) = crate::state::header_screen(node.tag) else {
return None;
};
let title_color = first_nonzero(props::header_title_color(node), 0xffffffff);
let mut bar = div()
.absolute()
.top_0()
.left_0()
.size_full()
.flex()
.items_center()
.justify_center()
.text_color(argb(title_color))
.text_size(gpui::px(18.0));
if !crate::state::header_has_title_subview(node.tag) {
bar = bar.child(SharedString::from(props::header_title(node).to_string()));
}
if crate::state::header_shows_back(node.tag) {
bar = bar.child(
div()
.absolute()
.left_0()
.top_0()
.h(gpui::px(HEADER_HEIGHT))
.w(gpui::px(HEADER_HEIGHT))
.flex()
.items_center()
.justify_center()
.text_color(argb(first_nonzero(
props::header_tint_color(node),
0xffffffff,
)))
.text_size(gpui::px(30.0))
.cursor_pointer()
.child("‹")
.on_mouse_down(MouseButton::Left, move |_event, window, cx| {
if let Some(handle) = store_handle(cx) {
handle.dispatch_event(
screen_tag,
"headerBackButtonClicked",
&serde_json::json!({}),
cx,
);
handle.request_frame(window);
}
cx.stop_propagation();
}),
);
}
Some(bar.into_any_element())
}
}
/// Escape dismisses the top screen the way a native gesture would.
///
/// The card runs its exit animation, the screen gets the `will` pair, and only
/// when the animation ends does it get the final progress, the `did` pair and
/// `topDismissed`. `native_dismiss_pending` tells the stack observer that this
/// dismissal already ran its animation, so it must not run a second one.
fn dismiss_top(
top: i32,
stack: i32,
tree: &TreeView,
handle: &StoreHandle,
window: &mut Window,
cx: &mut App,
) {
crate::state::with(|state| state.native_dismiss_pending = Some(top));
let incoming = previous_screen(top, stack, tree);
crate::lifecycle::dispatch_will(handle, Some(top), incoming, cx);
// The animation belongs to the screen being dismissed, not to the stack: a
// stack carries no `stackAnimation` prop of its own. A dismissal plays the
// entry mirrored, which is what `reverse` is for.
let animation = tree
.node(top)
.map(|node| props::stack_animation(&node).to_string())
.unwrap_or_default();
let mode = crate::transition::transition_mode(&animation)
.filter(|_| tree.animations_enabled())
.map(crate::transition::TransitionMode::reverse);
let duration = mode.map(|mode| mode.duration());
if let Some(mode) = mode {
let exit = crate::transition::transition(mode, crate::transition::Role::Exit, 1.0);
crate::state::with(|state| {
state.roles.insert(top, (crate::state::Role::Exit, 1.0));
});
handle.start_transition(top, exit, cx);
// The card underneath comes back to rest from wherever the push left
// it. Without this it stays offset for the whole dismissal.
if let Some(incoming) = incoming {
handle.clear_transition(incoming, cx);
}
handle.request_frame(window);
}
let finish = move |handle: &StoreHandle, cx: &mut App| {
handle.dispatch_event(
top,
"topTransitionProgress",
&serde_json::json!({"progress": 1, "closing": 1, "goingForward": 0}),
cx,
);
crate::lifecycle::dispatch_did(handle, Some(top), incoming, cx);
handle.dispatch_event(
top,
"topDismissed",
&serde_json::json!({ "dismissCount": 1 }),
cx,
);
};
match duration {
Some(duration) => {
let handle = handle.clone();
handle.after(duration, cx, move |handle, cx| finish(handle, cx));
}
None => finish(handle, cx),
}
}
/// A Fabric colour as GPUI sees it, alpha included.
fn argb(color: u32) -> gpui::Hsla {
let alpha = color >> 24;
gpui::rgba((color << 8) | alpha).into()
}
fn first_nonzero(value: u32, fallback: u32) -> u32 {
if value == 0 {
fallback
} else {
value
}
}
/// Whether the screen has a previous live screen in its stack to go back to.
fn should_show_back(node: &ComponentNode, tree: &TreeView) -> bool {
if props::header_hide_back(node) {
return false;
}
let Some(screen) = node.parent else {
return false;
};
let Some(stack) = stack_of(screen, tree) else {
return false;
};
let screens: Vec<i32> = tree
.children(stack)
.into_iter()
.filter(|tag| tree.is_live(*tag) && tree.node(*tag).is_some_and(|n| n.component == SCREEN))
.collect();
screens
.iter()
.position(|tag| *tag == screen)
.is_some_and(|position| position > 0)
}
/// Whether this header belongs to the screen currently on top of its stack.
fn is_top_header(node: &ComponentNode, tree: &TreeView) -> bool {
let Some(parent) = node.parent else {
return true;
};
let Some(stack) = stack_of(parent, tree) else {
return true;
};
let screens: Vec<i32> = tree
.children(stack)
.into_iter()
.filter(|tag| tree.node(*tag).is_some_and(|n| n.component == SCREEN))
.collect();
let is_last = screens.last().copied() == Some(parent);
// A screen that is leaving keeps its header while its transition runs, so
// the header does not blink out before the card has finished moving. An
// entry transition is never cleared once it ends, so only a running one
// counts: otherwise the first screen ever pushed keeps its header for good.
is_last
|| tree
.transition(parent)
.is_some_and(|state| state.progress < 1.0)
}
/// Whether the header config carries a subview that paints the title.
///
/// React Navigation puts a custom title in a `title` subview, and a centred one
/// in a `center` subview; either way that subview paints the title, so core must
/// not paint a second copy.
fn has_title_subview(config: &ComponentNode, tree: &TreeView) -> bool {
tree.children(config.tag)
.into_iter()
.filter_map(|tag| tree.node(tag))
.any(|child| {
child.component == HEADER_SUBVIEW
&& matches!(props::subview_type(&child), "title" | "center")
})
}
/// The live screen directly below `top` in its stack, if any.
fn previous_screen(top: i32, stack: i32, tree: &TreeView) -> Option<i32> {
let screens: Vec<i32> = tree
.children(stack)
.into_iter()
.filter(|tag| tree.is_live(*tag) && tree.node(*tag).is_some_and(|n| n.component == SCREEN))
.collect();
let index = screens.iter().position(|tag| *tag == top)?;
index.checked_sub(1).and_then(|i| screens.get(i)).copied()
}
/// The stack a screen sits in, walking up past intermediate containers.
fn stack_of(screen: i32, tree: &TreeView) -> Option<i32> {
tree.ancestors(screen)
.into_iter()
.find(|tag| tree.node(*tag).is_some_and(|n| n.component == STACK))
}
/// A header subview: positioned by its declared type.
pub struct HeaderSubviewRenderer;
impl ComponentRenderer for HeaderSubviewRenderer {
fn render(
&self,
_node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
None
}
fn layout_frame(&self, node: &ComponentNode, tree: &TreeView) -> Option<RectF> {
let mut frame = node.frame;
let Some(config) = node.parent else {
return Some(frame);
};
let container_width = tree
.node(config)
.map(|config| config.frame.w)
.unwrap_or(frame.w);
let tallest = tree
.children(node.tag)
.into_iter()
.filter_map(|tag| tree.node(tag))
.fold(0.0f32, |height, child| height.max(child.frame.h));
frame.x = match props::subview_type(node) {
"right" => container_width - frame.w - 12.0,
"center" | "title" => (container_width - frame.w) / 2.0,
"left" => 12.0,
_ => frame.x,
};
frame.y = ((HEADER_HEIGHT - tallest).max(0.0)) / 2.0;
Some(frame)
}
}
/// A screen's content area, inset by the header when one is showing.
pub struct ContentWrapperRenderer;
impl ComponentRenderer for ContentWrapperRenderer {
fn render(
&self,
_node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
None
}
fn layout_frame(&self, node: &ComponentNode, tree: &TreeView) -> Option<RectF> {
let Some(screen) = node.parent else {
return None;
};
let mut frame = node.frame;
// Only a header that is actually showing takes space. When it is hidden
// the C++ half already gave the wrapper the whole screen, so the Yoga
// frame is the answer and any adjustment here would move it twice.
if !header_hidden(screen, tree) {
frame.y += HEADER_HEIGHT;
frame.h = (frame.h - HEADER_HEIGHT).max(0.0);
}
Some(frame)
}
}
/// Whether a screen's header config is hidden, whether or not it exists.
fn header_hidden(screen: i32, tree: &TreeView) -> bool {
tree.children(screen)
.into_iter()
.filter_map(|tag| tree.node(tag))
.find(|node| node.component == HEADER_CONFIG)
.is_some_and(|config| props::header_hidden(&config))
}
/// The gap a modal sheet leaves around itself, and the size it is capped at.
const SHEET_MARGIN: f32 = 48.0;
const SHEET_MAX_WIDTH: f32 = 760.0;
const SHEET_MAX_HEIGHT: f32 = 640.0;
/// A modal presentation. It paints like a screen; the difference is that the
/// card underneath stays visible.
pub struct ModalScreenRenderer;
impl ComponentRenderer for ModalScreenRenderer {
fn render(
&self,
_node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
None
}
}
/// A full-window overlay paints over the whole window, not inside the tree.
pub struct FullWindowOverlayRenderer;
impl ComponentRenderer for FullWindowOverlayRenderer {
fn render(
&self,
_node: &ComponentNode,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
None
}
fn portal(&self, _props: &serde_json::Value) -> Option<Portal> {
Some(Portal {
visible: true,
close_request_event: None,
})
}
}
/// Every component name this module paints, and how to build its renderer.
pub const RENDERED_COMPONENTS: &[(&str, fn() -> Box<dyn ComponentRenderer>)] = &[
(STACK, || Box::new(StackRenderer)),
(SCREEN, || Box::new(ScreenRenderer)),
(MODAL_SCREEN, || Box::new(ModalScreenRenderer)),
(HEADER_CONFIG, || Box::new(HeaderConfigRenderer)),
(HEADER_SUBVIEW, || Box::new(HeaderSubviewRenderer)),
(CONTENT_WRAPPER, || Box::new(ContentWrapperRenderer)),
(FULL_WINDOW_OVERLAY, || Box::new(FullWindowOverlayRenderer)),
];
#[cfg(test)]
mod tests {
use super::*;
use crate::state;
use std::sync::Arc;
/// Builds a node with the props this module's C++ half would have produced.
fn node(component: &str, tag: i32, props: serde_json::Value) -> ComponentNode {
ComponentNode {
component: component.to_string(),
tag,
frame: RectF::default(),
scale: (1.0, 1.0),
props: Arc::new(props),
parent: None,
children: Vec::new(),
}
}
fn frame(x: f32, y: f32, w: f32, h: f32) -> RectF {
RectF { x, y, w, h }
}
/// A tree built from plain values, matching what core would hand a hook.
fn tree(
nodes: Vec<react_native_gpui::extensions::TestNode>,
edges: &[(i32, i32)],
top_inset: f32,
) -> TreeView<'static> {
TreeView::for_test(nodes, edges, top_inset)
}
fn entry(
tag: i32,
component: &'static str,
frame: RectF,
props: serde_json::Value,
) -> react_native_gpui::extensions::TestNode {
react_native_gpui::extensions::TestNode {
tag,
component: component.to_string(),
frame,
scale: (1.0, 1.0),
props,
}
}
/// A stack with one screen, its header config and its content wrapper.
///
/// `header` is the header config's props and `content_height` the wrapper's
/// Yoga height, which the C++ half computes for the whole screen.
fn stack_with_header(
header: serde_json::Value,
content_height: f32,
) -> (TreeView<'static>, i32, i32) {
let nodes = vec![
entry(
1,
STACK,
frame(0.0, 0.0, 400.0, 600.0),
serde_json::json!({}),
),
entry(
2,
SCREEN,
frame(0.0, 0.0, 400.0, 600.0),
serde_json::json!({"activityState": 2, "stackPresentation": "card"}),
),
entry(3, HEADER_CONFIG, frame(0.0, 0.0, 400.0, 56.0), header),
entry(
4,
CONTENT_WRAPPER,
frame(0.0, 0.0, 400.0, content_height),
serde_json::json!({}),
),
];
let edges = [(1, 2), (2, 3), (2, 4)];
(tree(nodes, &edges, 0.0), 3, 4)
}
#[test]
fn a_visible_header_pushes_the_content_down_by_its_height() {
// Yoga gives the wrapper the whole screen; a visible header takes the
// top 56 px of it, so the content starts below the bar.
let (tree, _, wrapper) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
let node = tree.node(wrapper).expect("wrapper is mounted");
let laid_out = ContentWrapperRenderer
.layout_frame(&node, &tree)
.expect("the wrapper is laid out");
assert_eq!(laid_out.y, HEADER_HEIGHT);
assert_eq!(laid_out.h, 600.0 - HEADER_HEIGHT);
}
#[test]
fn a_hidden_header_leaves_the_content_where_yoga_put_it() {
// The C++ half gives a hidden-header screen's wrapper the full height,
// so any adjustment here would move the content a second time: the
// reported bug was content sitting 56 px above the top of the window.
let (tree, _, wrapper) =
stack_with_header(serde_json::json!({"title": "Home", "hidden": true}), 600.0);
let node = tree.node(wrapper).expect("wrapper is mounted");
let laid_out = ContentWrapperRenderer
.layout_frame(&node, &tree)
.expect("the wrapper is laid out");
assert_eq!(laid_out.y, 0.0);
assert_eq!(laid_out.h, 600.0);
}
#[test]
fn the_header_sits_below_the_window_inset_and_is_one_bar_tall() {
let (tree, header, _) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
// A window whose title bar takes 28 px: the bar must start below it.
let tree = tree_with_inset(&tree, 28.0);
let node = tree.node(header).expect("header is mounted");
let laid_out = HeaderConfigRenderer
.layout_frame(&node, &tree)
.expect("the header is laid out");
assert_eq!(laid_out.y, 28.0);
assert_eq!(laid_out.h, HEADER_HEIGHT);
}
#[test]
fn a_hidden_header_takes_no_height_and_paints_nothing() {
let (tree, header, _) =
stack_with_header(serde_json::json!({"title": "Home", "hidden": true}), 600.0);
let node = tree.node(header).expect("header is mounted");
let laid_out = HeaderConfigRenderer
.layout_frame(&node, &tree)
.expect("the header is laid out");
assert_eq!(laid_out.h, 0.0);
assert!(HeaderConfigRenderer.hides_subtree(&node, &tree));
assert_eq!(
HeaderConfigRenderer.decorate(&node, &tree).display,
Some(false)
);
}
/// A header carrying a `title` or `center` subview must not paint a second
/// title of its own: the subview is the title.
#[test]
fn a_title_subview_suppresses_the_headers_own_title() {
for subview_type in ["title", "center"] {
let nodes = vec![
entry(
1,
STACK,
frame(0.0, 0.0, 400.0, 600.0),
serde_json::json!({}),
),
entry(
2,
SCREEN,
frame(0.0, 0.0, 400.0, 600.0),
serde_json::json!({"activityState": 2, "stackPresentation": "card"}),
),
entry(
3,
HEADER_CONFIG,
frame(0.0, 0.0, 400.0, 56.0),
serde_json::json!({"title": "Details 1"}),
),
entry(
4,
HEADER_SUBVIEW,
frame(0.0, 0.0, 80.0, 20.0),
serde_json::json!({ "type": subview_type }),
),
];
let edges = [(1, 2), (2, 3), (3, 4)];
let tree = tree(nodes, &edges, 0.0);
let config = tree.node(3).expect("header config is mounted");
assert!(
has_title_subview(&config, &tree),
"a {subview_type} subview paints the title"
);
}
}
#[test]
fn a_header_without_a_title_subview_paints_its_title() {
let (tree, header, _) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
let node = tree.node(header).expect("header is mounted");
assert!(!has_title_subview(&node, &tree));
}
#[test]
fn the_header_paints_the_colours_react_native_asked_for() {
let (tree, header, _) = stack_with_header(
serde_json::json!({
"title": "Home",
"backgroundColor": 0xff203040u32,
}),
600.0,
);
let node = tree.node(header).expect("header is mounted");
let decoration = HeaderConfigRenderer.decorate(&node, &tree);
assert_eq!(decoration.background, Some(0xff203040));
assert_ne!(decoration.display, Some(false));
}
#[test]
fn a_header_without_a_background_falls_back_to_the_default() {
let (tree, header, _) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
let node = tree.node(header).expect("header is mounted");
assert_eq!(
HeaderConfigRenderer.decorate(&node, &tree).background,
Some(0xff171923)
);
}
/// The same tree as `stack_with_header` but with a window inset.
fn tree_with_inset(source: &TreeView<'_>, inset: f32) -> TreeView<'static> {
let mut nodes: Vec<react_native_gpui::extensions::TestNode> = Vec::new();
let mut edges = Vec::new();
for tag in [1, 2, 3, 4] {
let node = source.node(tag).expect("node is mounted");
nodes.push(react_native_gpui::extensions::TestNode {
tag,
component: node.component,
frame: node.frame,
scale: node.scale,
props: (*node.props).clone(),
});
if let Some(parent) = node.parent {
edges.push((parent, tag));
}
}
tree(nodes, &edges, inset)
}
}
@@ -0,0 +1,396 @@
//! The stack observer: everything react-native-screens does between a mount
//! batch and a paint.
//!
//! Core tells an observer about a batch before it applies it, about each insert
//! and removal as it applies them, and about the batch once it is done. All the
//! navigation decisions — which stack is nested, which card is leaving, when a
//! lifecycle event fires, when a card is kept alive for its exit — are made here
//! and remembered in `state`.
use crate::props;
use crate::state::{self, PopPlan, Role};
use crate::transition::{self, Role as TransitionRole};
use react_native_gpui::extensions::{
BatchOpKind, BatchView, ComponentNode, StoreHandle, StoreObserver, TreeView,
};
use std::collections::HashSet;
/// The component names this module owns.
pub const STACK: &str = "RNSScreenStack";
pub const SCREEN: &str = "RNSScreen";
pub const HEADER_CONFIG: &str = "RNSScreenStackHeaderConfig";
pub const HEADER_SUBVIEW: &str = "RNSScreenStackHeaderSubview";
pub const CONTENT_WRAPPER: &str = "RNSScreenContentWrapper";
pub const MODAL_SCREEN: &str = "RNSModalScreen";
pub const FULL_WINDOW_OVERLAY: &str = "RNSFullWindowOverlay";
/// Watches the batch and drives the stack.
pub struct StackObserver;
impl StoreObserver for StackObserver {
fn before_batch(
&self,
batch: &BatchView,
tree: &TreeView,
handle: &StoreHandle,
cx: &mut gpui::App,
) {
let nested = nested_stacks(batch, tree);
if std::env::var_os("RN_GPUI_DEBUG_SCREENS").is_some() && !nested.is_empty() {
log::info!("[screens] nested stack tags={nested:?}");
}
let plans = pop_plans(batch, tree, &nested, handle, cx);
for (tag, plan) in &plans {
if plan.retain {
handle.retain_subtree(*tag);
}
}
state::with(|state| {
state.nested_stacks = nested;
state.pop_plans = plans;
});
}
fn on_mount(&self, node: &ComponentNode, _handle: &StoreHandle, _cx: &mut gpui::App) {
if node.component == FULL_WINDOW_OVERLAY {
log::info!(
"[screens] full window overlay created tag={} frame={}x{}",
node.tag,
node.frame.w,
node.frame.h
);
} else if crate::unsupported::unsupported_component(&node.component) {
log::error!(
"[screens] component {} is not implemented on gpui",
node.component
);
}
}
fn on_insert(
&self,
child: &ComponentNode,
parent: i32,
previous_children: &[i32],
tree: &TreeView,
handle: &StoreHandle,
cx: &mut gpui::App,
) {
if child.component != SCREEN || !is_outer_stack(parent) {
return;
}
let animation = props::stack_animation(child).to_string();
let presentation = props::stack_presentation(child).to_string();
log::info!(
"[screens] screen inserted tag={} animation={} presentation={} reduce_motion={}",
child.tag,
animation,
presentation,
!tree.animations_enabled()
);
let previous_top = previous_children
.iter()
.rev()
.copied()
.find(|tag| tree.node(*tag).is_some_and(|node| node.component == SCREEN));
let mode = transition::transition_mode(&animation).filter(|_| tree.animations_enabled());
if let Some(mode) = mode {
log::info!(
"[screens] push transition incoming={} outgoing={:?} animation={} mode={mode:?}",
child.tag,
previous_top,
animation
);
if presentation == "push" {
if let Some(previous) = previous_top {
let distance = transition::PARALLAX_DISTANCE;
handle.start_transition(
previous,
transition::transition(mode, TransitionRole::Exit, distance),
cx,
);
state::with(|state| {
state.roles.insert(previous, (Role::Exit, distance));
});
}
}
handle.start_transition(
child.tag,
transition::transition(mode, TransitionRole::Enter, 1.0),
cx,
);
state::with(|state| {
state.roles.insert(child.tag, (Role::Enter, 1.0));
});
}
crate::lifecycle::dispatch_will(handle, previous_top, Some(child.tag), cx);
match mode {
Some(mode) => {
let outgoing = previous_top;
let incoming = Some(child.tag);
let handle = handle.clone();
handle.after(mode.duration(), cx, move |handle, cx| {
if let Some(outgoing) = outgoing {
handle.clear_transition(outgoing, cx);
}
crate::lifecycle::dispatch_did(handle, outgoing, incoming, cx);
});
}
None => crate::lifecycle::dispatch_did(handle, previous_top, Some(child.tag), cx),
}
}
fn on_remove_child(
&self,
child: i32,
parent: i32,
retained: bool,
tree: &TreeView,
handle: &StoreHandle,
cx: &mut gpui::App,
) {
let Some(node) = tree.node(child) else {
return;
};
if node.component != SCREEN {
return;
}
let plan = state::with(|state| state.pop_plans.remove(&child));
let Some(plan) = plan else {
return;
};
let _ = parent;
if let Some(revealed) = plan.revealed {
handle.clear_transition(revealed, cx);
}
let native_dismiss = state::with(|state| state.native_dismiss_pending == Some(child));
let mode = transition::transition_mode(&plan.animation)
.filter(|_| tree.animations_enabled())
.map(transition::TransitionMode::reverse);
let retain = plan.retain && mode.is_some();
log::info!(
"[screens] pop transition outgoing={} incoming={:?} animation={} retained={retain}",
child,
plan.revealed,
plan.animation
);
if retain {
if let Some(mode) = mode {
handle.start_transition(
child,
transition::transition(mode, TransitionRole::Exit, 1.0),
cx,
);
state::with(|state| {
state.roles.insert(child, (Role::Exit, 1.0));
});
}
}
if !native_dismiss {
crate::lifecycle::dispatch_will(handle, Some(child), plan.revealed, cx);
}
if retained {
let duration = mode.map(|mode| mode.duration()).unwrap_or_default();
let revealed = plan.revealed;
let handle = handle.clone();
handle.after(duration, cx, move |handle, cx| {
handle.release_retained(child, cx);
log::info!("[screens] pop finished outgoing={child}");
crate::lifecycle::dispatch_did(handle, Some(child), revealed, cx);
});
} else if !native_dismiss {
crate::lifecycle::dispatch_did(handle, Some(child), plan.revealed, cx);
}
state::with(|state| {
if state.native_dismiss_pending == Some(child) {
state.native_dismiss_pending = None;
}
});
}
fn on_remove(&self, tag: i32, _handle: &StoreHandle, _cx: &mut gpui::App) {
state::with(|state| {
state.pop_plans.remove(&tag);
state.roles.remove(&tag);
state.headers.remove(&tag);
});
}
fn on_update(
&self,
node: &ComponentNode,
previous: &ComponentNode,
handle: &StoreHandle,
cx: &mut gpui::App,
) {
if node.component != SCREEN {
return;
}
let before = props::activity_state(previous);
let after = props::activity_state(node);
if before == after {
return;
}
for event in crate::lifecycle::lifecycle_events(before, after) {
log::info!(
"[screens] tag={} activity={}->{} event={event}",
node.tag,
before,
after
);
handle.dispatch_event(node.tag, event, &serde_json::json!({}), cx);
}
}
fn after_batch(&self, _tree: &TreeView, _handle: &StoreHandle, _cx: &mut gpui::App) {
state::clear_batch_state();
}
fn on_transition_frame(
&self,
tag: i32,
progress: f32,
handle: &StoreHandle,
cx: &mut gpui::App,
) {
let forward = state::with(|state| {
state
.roles
.get(&tag)
.is_none_or(|(role, distance)| *role == Role::Enter || *distance < 1.0)
});
handle.dispatch_event(
tag,
"topTransitionProgress",
&serde_json::json!({
"progress": progress,
"closing": if forward { 0 } else { 1 },
"goingForward": if forward { 1 } else { 0 }
}),
cx,
);
}
}
/// Whether `stack` is the outer stack, the only one that animates.
fn is_outer_stack(stack: i32) -> bool {
!state::with(|state| state.nested_stacks.contains(&stack))
}
/// Every stack that sits inside a screen, found from the batch topology before
/// any of it is applied.
fn nested_stacks(batch: &BatchView, tree: &TreeView) -> HashSet<i32> {
let stacks: Vec<i32> = batch
.ops
.iter()
.filter(|op| op.kind == BatchOpKind::Create && op.component == STACK)
.map(|op| op.tag)
.chain(
batch
.ops
.iter()
.filter(|op| op.kind != BatchOpKind::Create && op.component == STACK)
.map(|op| op.tag),
)
.collect();
let mut nested = HashSet::new();
for stack in stacks {
let mut cursor = Some(stack);
let mut visited = HashSet::new();
while let Some(tag) = cursor {
if !visited.insert(tag) {
break;
}
let Some(parent) = batch.parent_of(tag).or_else(|| tree.parent(tag)) else {
break;
};
if batch.component_of(parent, tree).as_deref() == Some(SCREEN) {
nested.insert(stack);
break;
}
cursor = Some(parent);
}
}
nested
}
/// The cards leaving in this batch that should animate their exit.
fn pop_plans(
batch: &BatchView,
tree: &TreeView,
nested: &HashSet<i32>,
handle: &StoreHandle,
cx: &mut gpui::App,
) -> std::collections::HashMap<i32, PopPlan> {
let removed: HashSet<i32> = batch
.ops
.iter()
.filter(|op| op.kind == BatchOpKind::Remove && op.component == SCREEN)
.map(|op| op.tag)
.collect();
let mut plans = std::collections::HashMap::new();
for op in batch
.ops
.iter()
.filter(|op| op.kind == BatchOpKind::Remove && op.component == SCREEN)
{
if nested.contains(&op.parent_tag) {
continue;
}
let Some(stack) = tree.node(op.parent_tag) else {
continue;
};
if stack.component != STACK {
continue;
}
let live: Vec<i32> = tree
.children(op.parent_tag)
.into_iter()
.filter(|tag| {
tree.is_live(*tag) && tree.node(*tag).is_some_and(|node| node.component == SCREEN)
})
.collect();
if live.last().copied() != Some(op.tag) {
continue;
}
let revealed = live
.iter()
.rev()
.find(|tag| !removed.contains(tag))
.copied();
let Some(screen) = tree.node(op.tag) else {
continue;
};
let animation = props::stack_animation(&screen).to_string();
let native_dismiss = state::with(|state| state.native_dismiss_pending == Some(op.tag));
let mode = transition::transition_mode(&animation).filter(|_| tree.animations_enabled());
let retain = !native_dismiss && revealed.is_some() && mode.is_some();
if retain {
log::info!(
"[screens] preserve outgoing={} descendants={}",
op.tag,
descendant_count(op.tag, tree)
);
}
plans.insert(
op.tag,
PopPlan {
revealed,
animation,
duration: mode.map(|mode| mode.duration()).unwrap_or_default(),
retain,
},
);
}
plans
}
/// How many nodes sit below `tag`, at any depth.
fn descendant_count(tag: i32, tree: &TreeView) -> usize {
let mut total = 0;
let mut stack = tree.children(tag);
while let Some(next) = stack.pop() {
total += 1;
stack.extend(tree.children(next));
}
total
}
@@ -0,0 +1,162 @@
//! The module's own state, kept outside core.
//!
//! Core hands an observer facts about a batch and forgets them; everything a
//! stack has to remember between batches — which stacks are nested, which cards
//! are leaving, what a dismissal already animated — is here.
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
/// What `decorate` learned about a header, for `content` to use.
#[derive(Clone, Copy, Debug, Default)]
pub struct HeaderFacts {
pub is_top: bool,
pub shows_back: bool,
pub has_title_subview: bool,
/// The screen this header belongs to, which is where a back-button event
/// has to go: React Native declares `onHeaderBackButtonClicked` on
/// `RNSScreen`, not on the header config.
pub screen: Option<i32>,
}
/// Records what `decorate` learned, so `content` can read it.
pub fn set_header_facts(
tag: i32,
is_top: bool,
shows_back: bool,
has_title_subview: bool,
screen: Option<i32>,
) {
with(|state| {
state.headers.insert(
tag,
HeaderFacts {
is_top,
shows_back,
has_title_subview,
screen,
},
);
});
}
/// Whether this header is the top screen's, as of the last `decorate` call.
pub fn header_is_top(tag: i32) -> bool {
with(|state| state.headers.get(&tag).is_some_and(|facts| facts.is_top))
}
/// Whether this header carries a `title` subview, as of the last `decorate` call.
///
/// When it does, that subview paints the title and core must not paint its own.
pub fn header_has_title_subview(tag: i32) -> bool {
with(|state| {
state
.headers
.get(&tag)
.is_some_and(|facts| facts.has_title_subview)
})
}
/// The screen a header belongs to, as of the last `decorate` call.
pub fn header_screen(tag: i32) -> Option<i32> {
with(|state| state.headers.get(&tag).and_then(|facts| facts.screen))
}
/// Whether this header shows a back button, as of the last `decorate` call.
pub fn header_shows_back(tag: i32) -> bool {
with(|state| {
state
.headers
.get(&tag)
.is_some_and(|facts| facts.shows_back)
})
}
/// A card that is leaving, and what should happen when it is gone.
#[derive(Clone, Debug)]
pub struct PopPlan {
/// The screen that becomes top when this one is gone.
pub revealed: Option<i32>,
/// The animation name React Native asked for, unparsed.
pub animation: String,
/// How long the exit runs.
pub duration: Duration,
/// Whether the card must stay attached so its exit can paint.
pub retain: bool,
}
/// Everything the module remembers between batches.
#[derive(Default)]
pub struct State {
/// A native dismissal already ran the exit animation, so the removal must
/// not run a second one and must not send the "will" events again.
pub native_dismiss_pending: Option<i32>,
/// Stacks that sit inside a screen, so only the outer stack animates.
pub nested_stacks: HashSet<i32>,
/// Cards leaving in this batch, by tag.
pub pop_plans: HashMap<i32, PopPlan>,
/// The cards this module started a transition on: whether it is an entry
/// (the screen is arriving) or an exit, and how far it travels.
pub roles: HashMap<i32, (Role, f32)>,
/// The latest facts about each header, written by `decorate`.
pub headers: HashMap<i32, HeaderFacts>,
}
/// Whether a transition the module started is an arrival or a departure.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Enter,
Exit,
}
fn state() -> &'static Mutex<State> {
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(State::default()))
}
/// Runs `f` with the module state.
pub fn with<R>(f: impl FnOnce(&mut State) -> R) -> R {
let mut state = state()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(&mut state)
}
/// Forgets the per-batch decisions. Call from `after_batch`.
pub fn clear_batch_state() {
with(|state| {
state.pop_plans.clear();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_state_is_cleared_but_lifetimes_are_not() {
with(|state| {
state.pop_plans.insert(
7,
PopPlan {
revealed: Some(3),
animation: "slide_from_right".to_string(),
duration: Duration::from_millis(240),
retain: true,
},
);
state.nested_stacks.insert(9);
state.native_dismiss_pending = Some(7);
});
clear_batch_state();
with(|state| {
assert!(state.pop_plans.is_empty());
assert!(state.nested_stacks.contains(&9));
assert_eq!(state.native_dismiss_pending, Some(7));
});
with(|state| {
state.nested_stacks.clear();
state.native_dismiss_pending = None;
});
}
}
@@ -0,0 +1,193 @@
//! The animation modes a stack accepts, and the poses each one corresponds to.
//!
//! Core owns the timing and the paint; this file is the module's own statement
//! of what "slide from the right" and "fade" mean as movement between two
//! poses, so core never learns a navigation name.
use react_native_gpui::extensions::{Pose, Transition};
use std::time::Duration;
/// A stack animation, as `stackAnimation` names it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransitionMode {
Fade,
SlideFromRight,
SlideFromLeft,
}
impl TransitionMode {
/// The mode the reverse of this one uses, so a pop plays the entry mirrored.
#[must_use]
pub fn reverse(self) -> Self {
match self {
Self::SlideFromRight => Self::SlideFromLeft,
Self::SlideFromLeft => Self::SlideFromRight,
Self::Fade => Self::Fade,
}
}
/// How long this mode runs.
pub fn duration(self) -> Duration {
match self {
Self::Fade => Duration::from_millis(150),
_ => Duration::from_millis(240),
}
}
}
/// The mode a `stackAnimation` string asks for. Unknown values fade, which is
/// what the platform's own default is.
pub fn transition_mode(animation: &str) -> Option<TransitionMode> {
match animation {
"none" => None,
"slide_from_right" | "simple_push" => Some(TransitionMode::SlideFromRight),
"slide_from_left" => Some(TransitionMode::SlideFromLeft),
"default" | "" | "fade" => Some(TransitionMode::Fade),
_ => Some(TransitionMode::Fade),
}
}
/// Whether entering or leaving.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Enter,
Exit,
}
/// The poses a mode and role correspond to.
///
/// Entering slides from off-screen to rest; leaving slides from rest to
/// off-screen; a fade keeps the node still while its opacity moves. The offset
/// is a fraction of the node's own width, so `distance` is how far it travels.
pub fn poses(mode: TransitionMode, role: Role, distance: f32) -> (Pose, Pose) {
let rest = Pose {
offset_x: 0.0,
opacity: 1.0,
};
let transparent = Pose {
offset_x: 0.0,
opacity: 0.0,
};
let off_screen = |sign: f32| Pose {
offset_x: sign * distance,
opacity: 1.0,
};
match (mode, role) {
(TransitionMode::Fade, Role::Enter) => (transparent, rest),
(TransitionMode::Fade, Role::Exit) => (rest, transparent),
(TransitionMode::SlideFromRight, Role::Enter) => (off_screen(1.0), rest),
(TransitionMode::SlideFromLeft, Role::Enter) => (off_screen(-1.0), rest),
(TransitionMode::SlideFromRight, Role::Exit) => (rest, off_screen(-1.0)),
(TransitionMode::SlideFromLeft, Role::Exit) => (rest, off_screen(1.0)),
}
}
/// The transition a mode and role describe.
pub fn transition(mode: TransitionMode, role: Role, distance: f32) -> Transition {
let (from, to) = poses(mode, role, distance);
Transition {
from,
to,
duration: mode.duration(),
}
}
/// The card underneath a push parallaxes a short way rather than leaving.
pub const PARALLAX_DISTANCE: f32 = 0.12;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_supported_animations_and_falls_back_to_fade() {
assert_eq!(transition_mode("none"), None);
assert_eq!(transition_mode("fade"), Some(TransitionMode::Fade));
assert_eq!(transition_mode("default"), Some(TransitionMode::Fade));
assert_eq!(transition_mode(""), Some(TransitionMode::Fade));
assert_eq!(
transition_mode("slide_from_right"),
Some(TransitionMode::SlideFromRight)
);
assert_eq!(
transition_mode("simple_push"),
Some(TransitionMode::SlideFromRight)
);
assert_eq!(
transition_mode("slide_from_left"),
Some(TransitionMode::SlideFromLeft)
);
assert_eq!(transition_mode("unsupported"), Some(TransitionMode::Fade));
}
#[test]
fn reversing_a_slide_swaps_its_direction() {
assert_eq!(
TransitionMode::SlideFromRight.reverse(),
TransitionMode::SlideFromLeft
);
assert_eq!(
TransitionMode::SlideFromLeft.reverse(),
TransitionMode::SlideFromRight
);
assert_eq!(TransitionMode::Fade.reverse(), TransitionMode::Fade);
}
#[test]
fn the_poses_reproduce_the_original_slide_and_fade_curves() {
for (mode, role, distance, expected) in [
(
TransitionMode::SlideFromRight,
Role::Enter,
1.0,
[1.0, 0.5, 0.0],
),
(
TransitionMode::SlideFromLeft,
Role::Enter,
1.0,
[-1.0, -0.5, 0.0],
),
(
TransitionMode::SlideFromRight,
Role::Exit,
1.0,
[0.0, -0.5, -1.0],
),
(
TransitionMode::SlideFromLeft,
Role::Exit,
1.0,
[0.0, 0.5, 1.0],
),
(
TransitionMode::SlideFromRight,
Role::Exit,
PARALLAX_DISTANCE,
[0.0, -0.06, -0.12],
),
] {
let transition = transition(mode, role, distance);
for (progress, expected_offset) in
[(0.0, expected[0]), (0.5, expected[1]), (1.0, expected[2])]
{
let pose = transition.pose_at(progress);
assert!(
(pose.offset_x - expected_offset).abs() < 0.001,
"{mode:?} {role:?} at {progress}: {} != {expected_offset}",
pose.offset_x
);
}
}
}
#[test]
fn a_fade_moves_opacity_and_nothing_else() {
let transition = transition(TransitionMode::Fade, Role::Enter, 1.0);
for (progress, expected) in [(0.0, 0.0), (0.5, 0.5), (1.0, 1.0)] {
let pose = transition.pose_at(progress);
assert!((pose.opacity - expected).abs() < 0.001);
assert!(pose.offset_x.abs() < 0.001);
}
assert_eq!(transition.duration, Duration::from_millis(150));
}
}
@@ -0,0 +1,39 @@
//! Components react-native-screens ships that this platform does not implement.
//!
//! They are named here so the module can say so once, instead of core carrying a
//! list of a library's components.
/// Whether this component is one the platform cannot render.
pub fn unsupported_component(name: &str) -> bool {
matches!(
name,
"RNSSearchBar"
| "RNSBottomTabs"
| "RNSBottomTabsScreen"
| "RNSScreenFooter"
| "RNSSplitView"
| "RNSSplitViewScreen"
) || name.starts_with("RNSSplitView")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifies_the_components_the_platform_cannot_render() {
for name in [
"RNSSearchBar",
"RNSBottomTabs",
"RNSBottomTabsScreen",
"RNSScreenFooter",
"RNSSplitView",
"RNSSplitViewScreen",
] {
assert!(unsupported_component(name), "{name} should be unsupported");
}
assert!(unsupported_component("RNSSplitViewSomethingElse"));
for name in ["RNSScreen", "RNSScreenStack", "View", "RNSScreenContainer"] {
assert!(!unsupported_component(name), "{name} should be supported");
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@react-native-gpui/screens",
"version": "0.1.0",
"description": "GPUI platform integration for react-native-screens",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-screens": "4.28.x"
},
"license": "MIT"
}
@@ -0,0 +1,36 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
crate: 'rn-gpui-screens',
componentCodegenPackage: 'react-native-screens',
componentDescriptors: [
'RNSScreenComponentDescriptor',
'RNSScreenContainerComponentDescriptor',
'RNSScreenContentWrapperComponentDescriptor',
'RNSScreenNavigationContainerComponentDescriptor',
'RNSScreenStackComponentDescriptor',
'RNSScreenStackHeaderConfigComponentDescriptor',
'RNSScreenStackHeaderSubviewComponentDescriptor',
'RNSModalScreenComponentDescriptor',
'RNSFullWindowOverlayComponentDescriptor',
],
componentHeaders: [
'react/renderer/components/rnscreens/ComponentDescriptors.h',
'react/renderer/components/rnscreens/RNSScreenComponentDescriptor.h',
'react/renderer/components/rnscreens/RNSScreenStackHeaderConfigComponentDescriptor.h',
'react/renderer/components/rnscreens/RNSScreenStackHeaderSubviewComponentDescriptor.h',
'react/renderer/components/rnscreens/RNSModalScreenComponentDescriptor.h',
'react/renderer/components/rnscreens/RNSFullWindowOverlayComponentDescriptor.h',
],
// Metro: replace these files of installed packages for gpui.
sourceOverrides: {
'react-native-screens/src/core.ts': './src/core.desktop.ts',
'react-native-screens/src/components/FullWindowOverlay.tsx':
'./src/FullWindowOverlay.desktop.tsx',
},
},
},
},
};
@@ -0,0 +1,19 @@
import React, { type PropsWithChildren } from 'react';
import { StyleSheet, useWindowDimensions } from 'react-native';
import FullWindowOverlayNativeComponent from 'react-native-screens/src/fabric/FullWindowOverlayNativeComponent';
import type { NativeProps } from 'react-native-screens/src/fabric/FullWindowOverlayNativeComponent';
export default function FullWindowOverlay({
children,
...props
}: PropsWithChildren<NativeProps>) {
const { width, height } = useWindowDimensions();
return (
<FullWindowOverlayNativeComponent
{...props}
style={[StyleSheet.absoluteFill, { width, height }, props.style]}
>
{children}
</FullWindowOverlayNativeComponent>
);
}
@@ -0,0 +1,28 @@
import './customDirectEventTypes.desktop';
// GPUI always ships the native screens implementation.
export const isNativePlatformSupported = true;
let ENABLE_SCREENS = isNativePlatformSupported;
export function enableScreens(shouldEnableScreens = true) {
ENABLE_SCREENS = shouldEnableScreens;
}
let ENABLE_FREEZE = false;
export function enableFreeze(shouldEnableReactFreeze = true) {
if (!isNativePlatformSupported) {
return;
}
ENABLE_FREEZE = shouldEnableReactFreeze;
}
export function screensEnabled() {
return ENABLE_SCREENS;
}
export function freezeEnabled() {
return ENABLE_FREEZE;
}
@@ -0,0 +1,16 @@
// The native stack header dispatches this upstream screen event to notify
// React Navigation that the user requested a native back action.
import { customDirectEventTypes } from 'react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
customDirectEventTypes.topHeaderBackButtonClicked = {
registrationName: 'onHeaderBackButtonClicked',
};
customDirectEventTypes.topWillAppear = { registrationName: 'onWillAppear' };
customDirectEventTypes.topWillDisappear = {
registrationName: 'onWillDisappear',
};
customDirectEventTypes.topAppear = { registrationName: 'onAppear' };
customDirectEventTypes.topDisappear = { registrationName: 'onDisappear' };
export { customDirectEventTypes };
@@ -0,0 +1,8 @@
# SVG shared Fabric props/shadow-node descriptors are supplied by RN Codegen
# and react-native-svg's published common C++ shadow-node headers.
set(_svg_codegen_dir "${RN_GPUI_PROJECT_ROOT}/desktop/autolink/codegen/rnsvg")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GpuiSvgModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INSTALLERS svg)
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INCLUDE_DIRS
"${_svg_codegen_dir}")
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "rn-gpui-svg"
version = "0.1.0"
edition = "2021"
[dependencies]
react-native-gpui = { version = "0.1" }
rn-gpui-sys = { version = "0.1" }
gpui = { version = "0.2" }
serde_json = "1"
+38
View File
@@ -0,0 +1,38 @@
//! Puts the C++ runtime's shared libraries on this crate's test binary rpath.
//!
//! `rn-gpui-svg`'s tests build gpui trees, which link `libjsi.dylib` and the rest
//! of the Node-API runtime. Core's `build.rs` emits
//! `cargo:rustc-link-arg=-Wl,-rpath,<dir>` for its own binary — the app — and that
//! flag is not inherited by a dependency's test binary, so the test process
//! aborted in dyld with `Library not loaded: @rpath/libjsi.dylib` before running a
//! single test.
//!
//! Core exports the directories it was given as `DEP_RN_GPUI_CORE_RPATHS`, which
//! is the same variable core's own `build.rs` reads. Re-emitting it here scopes
//! the flag to this crate's test binary, since `cargo:rustc-link-arg` without a
//! target suffix applies to the crate's own targets and not to the app that
//! depends on it.
fn main() {
println!("cargo:rerun-if-env-changed=DEP_RN_GPUI_CORE_RPATHS");
println!("cargo:rerun-if-changed=build.rs");
let Ok(dirs) = std::env::var("DEP_RN_GPUI_CORE_RPATHS") else {
return;
};
let dirs = dirs
.split('|')
.filter(|dir| !dir.is_empty())
.collect::<Vec<_>>();
if dirs.is_empty() {
return;
}
// MSVC and MinGW have no rpath, and the flag is rejected outright; Windows
// finds the DLLs through PATH, which the test runner already arranges.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
return;
}
for dir in dirs {
println!("cargo:rustc-link-arg=-Wl,-rpath,{dir}");
}
}
@@ -0,0 +1,141 @@
#include "GpuiModules.h"
#include "MountBatchBuilder.h"
#include <react/renderer/components/rnsvg/Props.h>
#include <folly/json.h>
#include <string>
#include <vector>
namespace rngpui {
using namespace facebook::react;
namespace {
constexpr const char* kSvgComponents[] = {
"RNSVGSvgView",
"RNSVGCircle",
"RNSVGEllipse",
"RNSVGLine",
"RNSVGPath",
"RNSVGRect",
"RNSVGGroup",
"RNSVGDefs",
"RNSVGClipPath",
"RNSVGLinearGradient",
"RNSVGRadialGradient",
"RNSVGText",
"RNSVGTSpan",
};
template <typename T>
folly::dynamic svgJsonValue(const T& value) {
return value;
}
folly::dynamic svgJsonValue(const Float& value) {
return value;
}
folly::dynamic svgJsonValue(const SharedColor& value) {
return value ? folly::dynamic(argb(value)) : folly::dynamic(nullptr);
}
folly::dynamic svgJsonValue(const std::vector<std::string>& values) {
folly::dynamic result = folly::dynamic::array();
for (const auto& value : values)
result.push_back(value);
return result;
}
folly::dynamic svgJsonValue(const std::vector<Float>& values) {
folly::dynamic result = folly::dynamic::array();
for (auto value : values)
result.push_back(value);
return result;
}
template <typename T>
folly::dynamic svgPropsJson(const T& props) {
folly::dynamic result = folly::dynamic::object();
#define RN_GPUI_COPY_SVG_PROP(name) \
if constexpr (requires { props.name; }) \
result[#name] = svgJsonValue(props.name)
RN_GPUI_COPY_SVG_PROP(name);
RN_GPUI_COPY_SVG_PROP(d);
RN_GPUI_COPY_SVG_PROP(x);
RN_GPUI_COPY_SVG_PROP(y);
RN_GPUI_COPY_SVG_PROP(x1);
RN_GPUI_COPY_SVG_PROP(y1);
RN_GPUI_COPY_SVG_PROP(x2);
RN_GPUI_COPY_SVG_PROP(y2);
RN_GPUI_COPY_SVG_PROP(cx);
RN_GPUI_COPY_SVG_PROP(cy);
RN_GPUI_COPY_SVG_PROP(r);
RN_GPUI_COPY_SVG_PROP(rx);
RN_GPUI_COPY_SVG_PROP(ry);
RN_GPUI_COPY_SVG_PROP(width);
RN_GPUI_COPY_SVG_PROP(height);
RN_GPUI_COPY_SVG_PROP(vbWidth);
RN_GPUI_COPY_SVG_PROP(vbHeight);
RN_GPUI_COPY_SVG_PROP(minX);
RN_GPUI_COPY_SVG_PROP(minY);
RN_GPUI_COPY_SVG_PROP(align);
RN_GPUI_COPY_SVG_PROP(meetOrSlice);
RN_GPUI_COPY_SVG_PROP(opacity);
RN_GPUI_COPY_SVG_PROP(color);
RN_GPUI_COPY_SVG_PROP(propList);
RN_GPUI_COPY_SVG_PROP(fill);
RN_GPUI_COPY_SVG_PROP(fillOpacity);
RN_GPUI_COPY_SVG_PROP(fillRule);
RN_GPUI_COPY_SVG_PROP(stroke);
RN_GPUI_COPY_SVG_PROP(strokeOpacity);
RN_GPUI_COPY_SVG_PROP(strokeWidth);
RN_GPUI_COPY_SVG_PROP(strokeLinecap);
RN_GPUI_COPY_SVG_PROP(strokeLinejoin);
RN_GPUI_COPY_SVG_PROP(strokeDasharray);
RN_GPUI_COPY_SVG_PROP(strokeDashoffset);
RN_GPUI_COPY_SVG_PROP(strokeMiterlimit);
RN_GPUI_COPY_SVG_PROP(matrix);
RN_GPUI_COPY_SVG_PROP(mask);
RN_GPUI_COPY_SVG_PROP(clipPath);
RN_GPUI_COPY_SVG_PROP(offset);
RN_GPUI_COPY_SVG_PROP(stopColor);
RN_GPUI_COPY_SVG_PROP(stopOpacity);
#undef RN_GPUI_COPY_SVG_PROP
return result;
}
folly::dynamic svgPropsJson(const std::string& component, const Props& props) {
#define RN_GPUI_SVG_PROPS(tag, type) \
if (component == tag) { \
if (auto typed = dynamic_cast<const type*>(&props)) \
return svgPropsJson(*typed); \
}
RN_GPUI_SVG_PROPS("RNSVGSvgView", RNSVGSvgViewProps)
RN_GPUI_SVG_PROPS("RNSVGCircle", RNSVGCircleProps)
RN_GPUI_SVG_PROPS("RNSVGEllipse", RNSVGEllipseProps)
RN_GPUI_SVG_PROPS("RNSVGLine", RNSVGLineProps)
RN_GPUI_SVG_PROPS("RNSVGPath", RNSVGPathProps)
RN_GPUI_SVG_PROPS("RNSVGRect", RNSVGRectProps)
RN_GPUI_SVG_PROPS("RNSVGGroup", RNSVGGroupProps)
RN_GPUI_SVG_PROPS("RNSVGDefs", RNSVGDefsProps)
RN_GPUI_SVG_PROPS("RNSVGClipPath", RNSVGClipPathProps)
RN_GPUI_SVG_PROPS("RNSVGLinearGradient", RNSVGLinearGradientProps)
RN_GPUI_SVG_PROPS("RNSVGRadialGradient", RNSVGRadialGradientProps)
RN_GPUI_SVG_PROPS("RNSVGText", RNSVGTextProps)
RN_GPUI_SVG_PROPS("RNSVGTSpan", RNSVGTSpanProps)
#undef RN_GPUI_SVG_PROPS
return folly::dynamic::object();
}
} // namespace
void rn_gpui_install_svg_module() {
for (const char* component : kSvgComponents) {
registerComponentProps(
component, [](const std::string& name, const ShadowView& view) {
if (!view.props)
return std::string("{}");
return folly::toJson(svgPropsJson(name, *view.props));
});
}
}
} // namespace rngpui
+465
View File
@@ -0,0 +1,465 @@
use gpui::{div, prelude::*, px, AnyElement, App, Window};
use react_native_gpui::extensions::{
register_component_renderer, ComponentNode, ComponentRenderer,
};
use rn_gpui_sys::ffi::RectF;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock, Weak};
const COMPONENTS: &[&str] = &[
"RNSVGSvgView",
"RNSVGCircle",
"RNSVGEllipse",
"RNSVGLine",
"RNSVGPath",
"RNSVGRect",
"RNSVGGroup",
"RNSVGDefs",
"RNSVGClipPath",
"RNSVGLinearGradient",
"RNSVGRadialGradient",
"RNSVGText",
"RNSVGTSpan",
"RNSVGMask",
"RNSVGSymbol",
"RNSVGPattern",
"RNSVGMarker",
"RNSVGTextPath",
"RNSVGUse",
];
struct SvgRenderer;
impl ComponentRenderer for SvgRenderer {
fn render(
&self,
node: &ComponentNode,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
if node.component != "RNSVGSvgView" {
return Some(div().into_any_element());
}
// The frame is in unscaled coordinates and this element is painted
// scaled, exactly as `ComponentRenderer::render` documents, so the box
// has to be scaled the same way core scales the element it would have
// built. `size_full()` sized the image to its parent instead: the
// document is rasterized at the window's scale factor, and an image
// carries no intrinsic size, so every SVG stretched to fill whatever
// surrounded it and painted over the text below it.
let frame = node.frame;
let (scale_x, scale_y) = node.scale;
let width = frame.w * scale_x;
let height = frame.h * scale_y;
let document = svg_document(node, frame, cx);
let scale = window.scale_factor();
let image = cached_svg_image(document, frame.w, frame.h, scale);
Some(
gpui::img(image)
.w(px(width))
.h(px(height))
.into_any_element(),
)
}
}
pub fn rn_gpui_register() {
for component in COMPONENTS {
register_component_renderer(*component, Arc::new(SvgRenderer));
}
}
fn svg_document(node: &ComponentNode, frame: RectF, cx: &App) -> String {
let props = &node.props;
let mut attributes = format!(
"xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\"",
svg_number(frame.w),
svg_number(frame.h)
);
for (name, attribute) in [
("color", "color"),
("fill", "fill"),
("stroke", "stroke"),
("strokeWidth", "stroke-width"),
("fillOpacity", "fill-opacity"),
("strokeOpacity", "stroke-opacity"),
("opacity", "opacity"),
] {
if let Some(value) = props.get(name).filter(|value| !value.is_null()) {
if let Some(value) = svg_value(value) {
attributes.push_str(&format!(" {attribute}=\"{}\"", svg_escape(&value)));
}
}
}
if let Some(cap) = props
.get("strokeLinecap")
.and_then(serde_json::Value::as_i64)
{
let cap = ["butt", "round", "square"]
.get(cap as usize)
.copied()
.unwrap_or("butt");
attributes.push_str(&format!(" stroke-linecap=\"{cap}\""));
}
if let Some(join) = props
.get("strokeLinejoin")
.and_then(serde_json::Value::as_i64)
{
let join = ["miter", "round", "bevel"]
.get(join as usize)
.copied()
.unwrap_or("miter");
attributes.push_str(&format!(" stroke-linejoin=\"{join}\""));
}
if let (Some(width), Some(height)) = (
props.get("vbWidth").and_then(serde_json::Value::as_f64),
props.get("vbHeight").and_then(serde_json::Value::as_f64),
) {
if width > 0.0 && height > 0.0 {
let min_x = props
.get("minX")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
let min_y = props
.get("minY")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
attributes.push_str(&format!(
" viewBox=\"{} {} {} {}\"",
svg_number(min_x as f32),
svg_number(min_y as f32),
svg_number(width as f32),
svg_number(height as f32)
));
if let Some(align) = props.get("align").and_then(serde_json::Value::as_str) {
let mode = match props
.get("meetOrSlice")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0)
{
1 => "slice",
2 => "none",
_ => "meet",
};
attributes.push_str(&format!(
" preserveAspectRatio=\"{} {}\"",
svg_escape(align),
mode
));
}
}
}
let mut document = format!("<svg {attributes}>");
for child in &node.children {
svg_node(child, cx, &mut document);
}
document.push_str("</svg>");
document
}
fn svg_node(node: &ComponentNode, cx: &App, output: &mut String) {
let props = &node.props;
let element = match node.component.as_str() {
"RNSVGCircle" => "circle",
"RNSVGEllipse" => "ellipse",
"RNSVGLine" => "line",
"RNSVGPath" => "path",
"RNSVGRect" => "rect",
"RNSVGGroup" => "g",
"RNSVGDefs" => "defs",
"RNSVGClipPath" => "clipPath",
"RNSVGLinearGradient" => "linearGradient",
"RNSVGRadialGradient" => "radialGradient",
"RNSVGMask" => "mask",
"RNSVGSymbol" => "symbol",
"RNSVGPattern" => "pattern",
"RNSVGMarker" => "marker",
"RNSVGText" => "text",
"RNSVGTSpan" => "tspan",
"RNSVGTextPath" => "textPath",
"RNSVGUse" => "use",
_ => "",
};
if element.is_empty() {
for child in &node.children {
svg_node(child, cx, output);
}
return;
}
let mut attributes = String::new();
for name in [
"id",
"name",
"href",
"d",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"cx",
"cy",
"r",
"rx",
"ry",
"width",
"height",
"color",
"fill",
"stroke",
"strokeWidth",
"fillOpacity",
"strokeOpacity",
"strokeDasharray",
"strokeDashoffset",
"strokeMiterlimit",
"opacity",
"fontSize",
"fontWeight",
"fontFamily",
"textLength",
"dominantBaseline",
"offset",
"stopColor",
"stopOpacity",
] {
if !svg_property_is_set(props, name) {
continue;
}
if let Some(value) = props.get(name) {
let attribute = match name {
"strokeWidth" => "stroke-width",
"fillOpacity" => "fill-opacity",
"strokeOpacity" => "stroke-opacity",
"strokeDasharray" => "stroke-dasharray",
"strokeDashoffset" => "stroke-dashoffset",
"strokeMiterlimit" => "stroke-miterlimit",
"fontSize" => "font-size",
"fontWeight" => "font-weight",
"fontFamily" => "font-family",
"dominantBaseline" => "dominant-baseline",
"stopColor" => "stop-color",
"stopOpacity" => "stop-opacity",
other => other,
};
let value = if value.is_null() && matches!(name, "fill" | "stroke") {
Some("none".to_owned())
} else {
svg_value(value)
};
if let Some(value) = value {
attributes.push_str(&format!(" {attribute}=\"{}\"", svg_escape(&value)));
}
}
}
if let Some(rule) = props
.get("fillRule")
.filter(|_| svg_property_is_set(props, "fillRule"))
.and_then(serde_json::Value::as_i64)
{
attributes.push_str(&format!(
" fill-rule=\"{}\"",
if rule == 0 { "evenodd" } else { "nonzero" }
));
}
if let Some(cap) = props
.get("strokeLinecap")
.filter(|_| svg_property_is_set(props, "strokeLinecap"))
.and_then(serde_json::Value::as_i64)
{
let cap = ["butt", "round", "square"]
.get(cap as usize)
.copied()
.unwrap_or("butt");
attributes.push_str(&format!(" stroke-linecap=\"{cap}\""));
}
if let Some(join) = props
.get("strokeLinejoin")
.filter(|_| svg_property_is_set(props, "strokeLinejoin"))
.and_then(serde_json::Value::as_i64)
{
let join = ["miter", "round", "bevel"]
.get(join as usize)
.copied()
.unwrap_or("miter");
attributes.push_str(&format!(" stroke-linejoin=\"{join}\""));
}
if let Some(matrix) = props.get("matrix").and_then(serde_json::Value::as_array) {
if matrix.len() >= 16 {
let values = [0, 1, 4, 5, 12, 13].map(|index| matrix[index].as_f64().unwrap_or(0.0));
attributes.push_str(&format!(
" transform=\"matrix({} {} {} {} {} {})\"",
values[0], values[1], values[2], values[3], values[4], values[5]
));
}
}
output.push('<');
output.push_str(element);
output.push_str(&attributes);
output.push('>');
for child in &node.children {
svg_node(child, cx, output);
}
output.push_str("</");
output.push_str(element);
output.push('>');
}
fn svg_property_is_set(props: &serde_json::Value, name: &str) -> bool {
if !matches!(
name,
"fill"
| "fillOpacity"
| "fillRule"
| "stroke"
| "strokeWidth"
| "strokeOpacity"
| "strokeLinecap"
| "strokeLinejoin"
| "strokeDasharray"
| "strokeDashoffset"
| "strokeMiterlimit"
) {
return true;
}
props
.get("propList")
.and_then(serde_json::Value::as_array)
.is_none_or(|properties| {
properties
.iter()
.any(|property| property.as_str() == Some(name))
})
}
fn svg_value(value: &serde_json::Value) -> Option<String> {
match value.get("type").and_then(serde_json::Value::as_i64) {
Some(1) => {
return value
.get("brushRef")
.and_then(serde_json::Value::as_str)
.map(|id| format!("url(#{id})"))
}
Some(2) => return Some("currentColor".to_owned()),
Some(3) => return Some("context-fill".to_owned()),
Some(4) => return Some("context-stroke".to_owned()),
_ => {}
}
if let Some(number) = value.as_f64() {
if number < 0.0 && number.fract() == 0.0 {
return Some(format!("#{:06x}", (number as i64 as u32) & 0x00ff_ffff));
}
if (0x0100_0000..=0xffff_ffff).contains(&(number as u64)) {
let argb = number as u32;
return Some(format!(
"rgba({}, {}, {}, {})",
(argb >> 16) & 0xff,
(argb >> 8) & 0xff,
argb & 0xff,
((argb >> 24) & 0xff) as f32 / 255.0
));
}
return Some(svg_number(number as f32));
}
if let Some(value) = value.as_str() {
return Some(value.to_owned());
}
if let Some(value) = value.as_array() {
return Some(
value
.iter()
.filter_map(svg_value)
.collect::<Vec<_>>()
.join(" "),
);
}
value
.get("payload")
.and_then(svg_value)
.or_else(|| value.get("value").and_then(svg_value))
}
fn svg_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn svg_number(value: f32) -> String {
if value.fract() == 0.0 {
format!("{value:.0}")
} else {
value.to_string()
}
}
fn cached_svg_image(
document: String,
width: f32,
height: f32,
scale: f32,
) -> std::sync::Arc<gpui::Image> {
type SvgCache = HashMap<(u64, u32, u32, u32), Weak<gpui::Image>>;
static CACHE: OnceLock<Mutex<SvgCache>> = OnceLock::new();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
document.hash(&mut hasher);
let key = (
hasher.finish(),
width.to_bits(),
height.to_bits(),
scale.to_bits(),
);
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(image) = cache.get(&key).and_then(Weak::upgrade) {
return image;
}
let image = std::sync::Arc::new(gpui::Image::from_bytes(
gpui::ImageFormat::Svg,
document.into_bytes(),
));
cache.insert(key, std::sync::Arc::downgrade(&image));
if cache.len() > 512 {
cache.retain(|_, image| image.strong_count() > 0);
}
image
}
#[cfg(test)]
mod svg_paint_tests {
use super::{svg_property_is_set, svg_value};
use serde_json::json;
#[test]
fn brushes_keep_current_color_and_reference_semantics() {
assert_eq!(
svg_value(&json!({"type": 2})).as_deref(),
Some("currentColor")
);
assert_eq!(
svg_value(&json!({"type": 1, "brushRef": "gradient"})).as_deref(),
Some("url(#gradient)")
);
assert_eq!(
svg_value(&json!({"type": 0, "payload": 0xff64d2ff_u32})).as_deref(),
Some("rgba(100, 210, 255, 1)")
);
}
#[test]
fn default_black_fill_does_not_override_inherited_none() {
let child = json!({"fill": {"type": 0, "payload": 0xff000000_u32}, "strokeLinecap": 0, "propList": []});
assert!(!svg_property_is_set(&child, "fill"));
assert!(!svg_property_is_set(&child, "strokeLinecap"));
let group = json!({"fill": null, "stroke": {"type": 2}, "propList": ["fill", "stroke"]});
assert!(svg_property_is_set(&group, "fill"));
assert!(svg_property_is_set(&group, "stroke"));
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@react-native-gpui/svg",
"version": "0.1.0",
"description": "GPUI Fabric renderer adapter for react-native-svg",
"files": [
"desktop",
"react-native.config.js"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-svg": "15.15.x"
},
"license": "MIT"
}
@@ -0,0 +1,51 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
crate: 'rn-gpui-svg',
componentCodegenPackage: 'react-native-svg',
componentSourcePackage: 'react-native-svg',
componentDescriptors: [
'RNSVGCircleComponentDescriptor',
'RNSVGClipPathComponentDescriptor',
'RNSVGDefsComponentDescriptor',
'RNSVGFeBlendComponentDescriptor',
'RNSVGFeColorMatrixComponentDescriptor',
'RNSVGFeCompositeComponentDescriptor',
'RNSVGFeFloodComponentDescriptor',
'RNSVGFeGaussianBlurComponentDescriptor',
'RNSVGFeMergeComponentDescriptor',
'RNSVGFeOffsetComponentDescriptor',
'RNSVGFilterComponentDescriptor',
'RNSVGEllipseComponentDescriptor',
'RNSVGForeignObjectComponentDescriptor',
'RNSVGGroupComponentDescriptor',
'RNSVGLinearGradientComponentDescriptor',
'RNSVGLineComponentDescriptor',
'RNSVGMarkerComponentDescriptor',
'RNSVGMaskComponentDescriptor',
'RNSVGPathComponentDescriptor',
'RNSVGPatternComponentDescriptor',
'RNSVGRadialGradientComponentDescriptor',
'RNSVGRectComponentDescriptor',
'RNSVGSymbolComponentDescriptor',
'RNSVGTextComponentDescriptor',
'RNSVGTextPathComponentDescriptor',
'RNSVGTSpanComponentDescriptor',
'RNSVGUseComponentDescriptor',
'RNSVGSvgViewComponentDescriptor',
],
componentHeaders: [
'react/renderer/components/rnsvg/ComponentDescriptors.h',
'react/renderer/components/rnsvg/RNSVGComponentDescriptors.h',
],
componentIncludeDirs: ['common/cpp'],
componentSources: [
'common/cpp/react/renderer/components/rnsvg/RNSVGShadowNodes.cpp',
'common/cpp/react/renderer/components/rnsvg/RNSVGLayoutableShadowNode.cpp',
],
},
},
},
};
@@ -0,0 +1,35 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/worklets was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
set(_gpui_app_root "${RN_GPUI_PROJECT_ROOT}")
find_program(_gpui_node node REQUIRED)
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('react-native-worklets/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _worklets_resolve_result
OUTPUT_VARIABLE _worklets_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _worklets_resolve_result EQUAL 0 OR NOT EXISTS "${_worklets_package_json}")
message(FATAL_ERROR "react-native-worklets could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_worklets_root "${_worklets_package_json}" DIRECTORY)
set(_worklets_common "${_worklets_root}/Common/cpp")
if(NOT EXISTS "${_worklets_common}/worklets/NativeModules/WorkletsModuleProxy.h")
message(FATAL_ERROR "react-native-worklets 0.13.x was not found in ${_gpui_app_root} or any ancestor node_modules directory")
endif()
file(GLOB_RECURSE _worklets_sources CONFIGURE_DEPENDS "${_worklets_common}/worklets/*.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES
${_worklets_sources}
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUIWorkletsModule.cpp"
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUIPlatformLogger.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS
"${_worklets_common}"
"${_worklets_root}")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_DEFINITIONS
"WORKLETS_VERSION=0.13.0")
# The frame hook is how worklets see every frame; the platform compiles this
# glue only because the package is installed.
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GpuiWorkletsFrameModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INSTALLERS worklets)
@@ -0,0 +1,27 @@
#include <worklets/Tools/PlatformLogger.h>
#include <glog/logging.h>
namespace worklets {
void PlatformLogger::log(const char* str) {
LOG(INFO) << str;
}
void PlatformLogger::log(const std::string& str) {
LOG(INFO) << str;
}
void PlatformLogger::log(const double value) {
LOG(INFO) << value;
}
void PlatformLogger::log(const int value) {
LOG(INFO) << value;
}
void PlatformLogger::log(const bool value) {
LOG(INFO) << (value ? "true" : "false");
}
} // namespace worklets
@@ -0,0 +1,336 @@
#include "GPUIWorkletsModule.h"
#include "rn-gpui-sys/src/lib.rs.h"
#include <glog/logging.h>
#include <worklets/Tools/JSLogger.h>
#include <worklets/WorkletRuntime/RuntimeBindings.h>
#include <worklets/WorkletRuntime/WorkletRuntime.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <stdexcept>
#include <thread>
#include <utility>
#include "GpuiModules.h"
namespace {
bool animationTracingEnabled() {
static const bool enabled = std::getenv("RN_GPUI_DEBUG_ANIMATION") != nullptr;
return enabled;
}
std::mutex moduleMutex;
std::weak_ptr<gpui_worklets::Module> moduleInstance;
std::atomic<uint64_t> workletFrames{0};
std::atomic<uint64_t> workletRafRequests{0};
class GPUIScheduler final : public worklets::UIScheduler {
protected:
bool queryIsOnUIThread() const override {
return gpui_worklets::isOnMainThread();
}
public:
void scheduleOnUI(std::function<void()> job) override {
static std::atomic<uint64_t> scheduled{0};
const auto count = ++scheduled;
if (animationTracingEnabled() && count <= 5) {
LOG(INFO) << "[gpui-worklets] scheduleOnUI count=" << count
<< " onUI=" << isOnUIThread();
}
if (isOnUIThread()) {
job();
return;
}
UIScheduler::scheduleOnUI(std::move(job));
rngpui::requestFrames("worklets", true);
}
};
worklets::RuntimeBindings::NativeLoggingHook nativeLoggingHook() {
return [](facebook::jsi::Runtime& runtime,
const facebook::jsi::Value&,
const facebook::jsi::Value* args,
size_t count) -> facebook::jsi::Value {
if (count != 2) {
throw std::invalid_argument("nativeLoggingHook takes 2 arguments");
}
const auto message = args[0].asString(runtime).utf8(runtime);
LOG(INFO) << "[worklet] " << message;
return facebook::jsi::Value::undefined();
};
}
} // namespace
namespace gpui_worklets {
namespace {
std::mutex uiThreadMutex;
std::thread::id uiThreadId;
} // namespace
bool isOnMainThread() {
// The runtime records the main thread before its loop runs. `onFrame` only
// notes it once a frame arrives, and `UIScheduler::isOnUIThread` caches the
// first answer per thread: a query that comes first, such as a layout
// animation flush, would pin "not the UI thread" on the main thread for good.
if (rngpui::rn_gpui_is_main_thread()) {
return true;
}
std::lock_guard<std::mutex> lock(uiThreadMutex);
return uiThreadId != std::thread::id{} &&
uiThreadId == std::this_thread::get_id();
}
void noteMainThread() {
std::lock_guard<std::mutex> lock(uiThreadMutex);
if (uiThreadId == std::thread::id{}) {
uiThreadId = std::this_thread::get_id();
}
}
Module::Module(std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
: NativeWorkletsModuleSpec(std::move(jsInvoker)) {}
Module::~Module() {
rngpui::reportPendingWork("worklets", 0);
rngpui::requestFrames("worklets", false);
if (initializer_) {
initializer_->invalidate();
}
std::lock_guard<std::mutex> lock(moduleMutex);
if (moduleInstance.lock().get() == this) {
moduleInstance.reset();
}
}
void Module::initialize(facebook::jsi::Runtime& runtime) {
if (initializer_)
return;
uiScheduler_ = std::make_shared<GPUIScheduler>();
runtimeStatus_ = std::make_shared<worklets::RNRuntimeStatus>();
jsScheduler_ = std::make_shared<worklets::JSScheduler>(
runtime, jsInvoker_, [] { return true; });
std::weak_ptr<Module> weakModule = shared_from_this();
auto runtimeBindings =
std::make_shared<worklets::RuntimeBindings>(worklets::RuntimeBindings{
.requestAnimationFrame =
[weakModule](std::function<void(double)> callback) {
if (auto module = weakModule.lock()) {
module->requestAnimationFrame(std::move(callback));
}
},
.nativeLoggingHook = nativeLoggingHook(),
.networkingBackend = nullptr,
});
initializer_ = std::make_shared<worklets::WorkletsModuleProxyInitializer>(
jsScheduler_, uiScheduler_, runtimeBindings, runtimeStatus_);
LOG(INFO) << "[gpui-worklets] initialized runtime bindings";
auto initializer = initializer_;
std::thread([initializer] { initializer->prepareProxy(); }).detach();
}
bool Module::installTurboModule(
facebook::jsi::Runtime& runtime,
bool bundleModeEnabled) {
initialize(runtime);
if (bundleModeEnabled) {
throw facebook::jsi::JSError(
runtime, "GPUI Worklets does not support Bundle Mode yet");
}
proxy_ = initializer_->finalize(runtime, false, {});
LOG(INFO) << "[gpui-worklets] WorkletsModule installed";
return true;
}
bool Module::prepareBundleMode(facebook::jsi::Runtime& runtime) {
throw facebook::jsi::JSError(
runtime, "GPUI Worklets does not support Bundle Mode yet");
}
bool Module::start(facebook::jsi::Runtime& runtime) {
if (!proxy_)
throw facebook::jsi::JSError(
runtime, "WorkletsModule.installTurboModule must run before start");
proxy_->start();
LOG(INFO) << "[gpui-worklets] UI runtime started";
return true;
}
bool Module::toggleSlowAnimationsOnUIRuntime(facebook::jsi::Runtime&) {
return false;
}
void Module::requestAnimationFrame(std::function<void(double)> callback) {
const auto count = ++workletRafRequests;
if (animationTracingEnabled() && count <= 5) {
LOG(INFO) << "[gpui-worklets] requestAnimationFrame count=" << count;
}
{
std::lock_guard<std::mutex> lock(callbacksMutex_);
frameCallbacks_.push_back(std::move(callback));
}
rngpui::requestFrames("worklets", true);
}
void Module::onFrame(double timestampMs) {
const auto count = ++workletFrames;
if (animationTracingEnabled()) {
static double windowStartMs = timestampMs;
static double previousFrameMs = timestampMs;
static double maximumGapMs = 0;
maximumGapMs = std::max(maximumGapMs, timestampMs - previousFrameMs);
previousFrameMs = timestampMs;
if (count % 60 == 0) {
LOG(INFO) << "[gpui-worklets] native frame cadence frames=60 elapsed_ms="
<< timestampMs - windowStartMs
<< " max_frame_gap_ms=" << maximumGapMs;
windowStartMs = timestampMs;
maximumGapMs = 0;
}
}
if (animationTracingEnabled() && (count <= 5 || count % 600 == 0)) {
LOG(INFO) << "[gpui-worklets] GPUI frame count=" << count;
}
// Frame callbacks are platform entry points, outside Worklets' guarded
// invocation paths. Report JS exceptions on RN rather than unwinding across
// the CXX boundary and terminating the desktop process.
const auto reportError = [this](const facebook::jsi::JSError& error) {
LOG(ERROR) << "[gpui-worklets] " << error.what();
worklets::JSLogger::reportFatalErrorOnJS(
jsScheduler_,
{.message = error.getMessage(),
.stack = error.getStack(),
.name = "WorkletsError"});
};
try {
if (uiScheduler_)
uiScheduler_->triggerUI();
} catch (const facebook::jsi::JSError& error) {
reportError(error);
}
// Worklets has a perpetual native heartbeat even when its JS RAF queue is
// empty. Test synchronization observes application callbacks, not that beat.
if (std::getenv("RN_GPUI_TEST_DRIVER")) {
if (auto runtime = uiRuntime()) {
runtime->runSync([](facebook::jsi::Runtime& rt) {
auto global = rt.global();
if (!global.hasProperty(rt, "requestAnimationFrame") ||
(global.hasProperty(rt, "__gpuiTestRaf") &&
facebook::jsi::Value::strictEquals(
rt,
global.getProperty(rt, "requestAnimationFrame"),
global.getProperty(rt, "__gpuiTestRaf"))))
return;
global.setProperty(
rt,
"__gpuiReportPendingRaf",
facebook::jsi::Function::createFromHostFunction(
rt,
facebook::jsi::PropNameID::forAscii(
rt, "__gpuiReportPendingRaf"),
1,
[](facebook::jsi::Runtime&,
const facebook::jsi::Value&,
const facebook::jsi::Value* args,
size_t count) {
if (count == 1 && args[0].isNumber())
rngpui::reportPendingWork(
"worklets", static_cast<uint64_t>(args[0].getNumber()));
return facebook::jsi::Value::undefined();
}));
rt.evaluateJavaScript(
std::make_shared<facebook::jsi::StringBuffer>(R"JS(
(() => {
const raf = globalThis.requestAnimationFrame;
const cancel = globalThis.cancelAnimationFrame;
const pending = new Set();
const report = () => globalThis.__gpuiReportPendingRaf(pending.size);
globalThis.requestAnimationFrame = callback => {
let id;
id = raf(timestamp => {
pending.delete(id); report(); callback(timestamp);
});
pending.add(id); report(); return id;
};
globalThis.cancelAnimationFrame = id => {
pending.delete(id); report(); cancel(id);
};
globalThis.__gpuiTestRaf = globalThis.requestAnimationFrame;
report();
})();
)JS"),
"gpui-test-raf.js");
});
}
}
std::vector<std::function<void(double)>> callbacks;
{
std::lock_guard<std::mutex> lock(callbacksMutex_);
callbacks.swap(frameCallbacks_);
}
if (animationTracingEnabled() && !callbacks.empty() &&
(count <= 5 || count % 60 == 0)) {
LOG(INFO) << "[gpui-worklets] callbacks on frame=" << callbacks.size();
}
for (auto& callback : callbacks) {
try {
callback(timestampMs);
} catch (const facebook::jsi::JSError& error) {
reportError(error);
}
}
{
std::lock_guard<std::mutex> lock(callbacksMutex_);
if (frameCallbacks_.empty()) {
rngpui::requestFrames("worklets", false);
}
}
}
std::shared_ptr<worklets::WorkletRuntime> Module::uiRuntime() const {
return proxy_ ? proxy_->getUIWorkletRuntime() : nullptr;
}
std::shared_ptr<worklets::UIScheduler> Module::uiScheduler() const {
return uiScheduler_;
}
void onFrame(double timestampMs) {
noteMainThread();
std::shared_ptr<Module> module;
{
std::lock_guard<std::mutex> lock(moduleMutex);
module = moduleInstance.lock();
}
if (module)
module->onFrame(timestampMs);
}
std::shared_ptr<Module> currentModule() {
std::lock_guard<std::mutex> lock(moduleMutex);
return moduleInstance.lock();
}
std::shared_ptr<facebook::react::TurboModule> createModule(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
if (name != Module::kModuleName)
return nullptr;
auto module = std::make_shared<Module>(std::move(jsInvoker));
{
std::lock_guard<std::mutex> lock(moduleMutex);
moduleInstance = module;
}
return module;
}
} // namespace gpui_worklets
std::shared_ptr<facebook::react::TurboModule> create_gpui_worklets_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
return gpui_worklets::createModule(name, std::move(jsInvoker));
}
@@ -0,0 +1,60 @@
#pragma once
#include <GPUIWorkletsSpec.h>
#include <ReactCommon/CallInvoker.h>
#include <ReactCommon/TurboModule.h>
#include <worklets/NativeModules/WorkletsModuleProxy.h>
#include <worklets/NativeModules/WorkletsModuleProxyInitializer.h>
#include <worklets/Tools/JSScheduler.h>
#include <worklets/Tools/RNRuntimeStatus.h>
#include <worklets/Tools/UIScheduler.h>
#include <functional>
#include <memory>
#include <mutex>
#include <vector>
namespace gpui_worklets {
bool isOnMainThread();
void noteMainThread();
class Module final : public facebook::react::NativeWorkletsModuleSpec<Module> {
public:
explicit Module(std::shared_ptr<facebook::react::CallInvoker> jsInvoker);
~Module() override;
void onFrame(double timestampMs);
void requestAnimationFrame(std::function<void(double)> callback);
std::shared_ptr<worklets::WorkletRuntime> uiRuntime() const;
std::shared_ptr<worklets::UIScheduler> uiScheduler() const;
bool installTurboModule(
facebook::jsi::Runtime& runtime,
bool bundleModeEnabled);
bool prepareBundleMode(facebook::jsi::Runtime& runtime);
bool start(facebook::jsi::Runtime& runtime);
bool toggleSlowAnimationsOnUIRuntime(facebook::jsi::Runtime& runtime);
private:
void initialize(facebook::jsi::Runtime& runtime);
std::shared_ptr<worklets::UIScheduler> uiScheduler_;
std::shared_ptr<worklets::JSScheduler> jsScheduler_;
std::shared_ptr<worklets::RNRuntimeStatus> runtimeStatus_;
std::shared_ptr<worklets::WorkletsModuleProxyInitializer> initializer_;
std::shared_ptr<worklets::WorkletsModuleProxy> proxy_;
std::mutex callbacksMutex_;
std::vector<std::function<void(double)>> frameCallbacks_;
};
void onFrame(double timestampMs);
std::shared_ptr<Module> currentModule();
std::shared_ptr<facebook::react::TurboModule> createModule(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker);
} // namespace gpui_worklets
std::shared_ptr<facebook::react::TurboModule> create_gpui_worklets_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker);
@@ -0,0 +1,13 @@
#include "GpuiModules.h"
namespace gpui_worklets {
void onFrame(double timestampMs);
}
namespace rngpui {
void rn_gpui_install_worklets_module() {
registerFrameCallback(
[](double timestampMs) { gpui_worklets::onFrame(timestampMs); });
}
} // namespace rngpui
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@react-native-gpui/worklets",
"version": "0.1.0",
"description": "GPUI platform bindings for react-native-worklets",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-worklets": "0.13.x"
},
"codegenConfig": {
"name": "GPUIWorkletsSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"license": "MIT"
}
@@ -0,0 +1,10 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
cppProvider: 'create_gpui_worklets_module',
},
},
},
};
@@ -0,0 +1,10 @@
import { TurboModuleRegistry, type TurboModule } from 'react-native';
export interface Spec extends TurboModule {
installTurboModule(bundleModeEnabled: boolean): boolean;
prepareBundleMode(): boolean;
toggleSlowAnimationsOnUIRuntime(): boolean;
start(): boolean;
}
export default TurboModuleRegistry.get<Spec>('WorkletsModule');
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import {
collectNodeAddons,
createLibrary,
initDesktop,
initProject,
runDesktop,
} from '../src/commands.js';
import { autolink } from '../src/autolink.js';
const requestedCommand = process.argv[2];
const command = requestedCommand === 'run-desktop' ? 'run' : requestedCommand;
const option = name => {
const index = process.argv.indexOf(name);
return index < 0 ? undefined : process.argv[index + 1];
};
const hasFlag = name => process.argv.includes(name);
const projectRoot = path.resolve(option('--project-root') ?? process.cwd());
const desktopRoot = path.join(projectRoot, 'desktop');
const run = (bin, args) =>
execFileSync(bin, args, { cwd: projectRoot, stdio: 'inherit' });
if (hasFlag('--help') || hasFlag('-h')) {
console.log(
'usage: npx react-native run-desktop [--project-root path] [--port 8082] [--release] [--features feature,...] [--no-launch]',
);
process.exit(0);
}
if (command === 'init-desktop') {
initDesktop({ projectRoot, skipInstall: hasFlag('--skip-install') });
process.exit(0);
}
if (command === 'create-library') {
createLibrary({
libraryName: process.argv[3],
type: option('--type') ?? 'rust',
destination: option('--directory'),
moduleName: option('--module-name'),
packageName: option('--package-name'),
});
process.exit(0);
}
if (command === 'init') {
const appName = process.argv[3];
if (!appName || !/^[A-Za-z][A-Za-z0-9_-]*$/u.test(appName)) {
console.error('usage: react-native-gpui init <AppName> [--directory path]');
process.exit(2);
}
const destination = path.resolve(option('--directory') ?? appName);
const initArgs = process.argv.slice(4);
for (let index = 0; index < initArgs.length; index += 1) {
if (
initArgs[index] === '--directory' ||
initArgs[index] === '--project-root'
) {
initArgs.splice(index, 2);
index -= 1;
}
}
initProject({ appName, destination, initArgs });
process.exit(0);
}
if (
!['autolink', 'bundle', 'collect-node-addons', 'prebuilt', 'run'].includes(
command,
)
) {
console.error(
'usage: react-native-gpui <init|init-desktop|create-library|autolink|bundle|collect-node-addons|package-desktop|prebuilt|run> [options]',
);
process.exit(2);
}
if (command === 'collect-node-addons') {
collectNodeAddons({
bundle: option('--bundle'),
out: option('--out'),
platform: option('--platform'),
});
process.exit(0);
}
if (command === 'run') {
await runDesktop({
projectRoot,
entryFile: option('--entry-file'),
bundle: option('--bundle'),
config: option('--config'),
release: hasFlag('--release'),
features: option('--features'),
noLaunch: hasFlag('--no-launch'),
port: option('--port'),
host: option('--host'),
});
process.exit(0);
}
if (command === 'bundle') {
const entryFile = option('--entry-file') ?? 'index.js';
const bundlePath = path.resolve(
option('--bundle') ?? path.join(desktopRoot, 'build/index.bundle'),
);
fs.mkdirSync(path.dirname(bundlePath), { recursive: true });
const bundleArgs = [
'react-native',
'bundle',
'--platform',
'desktop',
'--entry-file',
entryFile,
'--bundle-output',
bundlePath,
'--assets-dest',
path.join(path.dirname(bundlePath), 'assets'),
'--dev',
'false',
];
const metroConfig =
option('--config') ?? path.join(desktopRoot, 'metro.config.js');
if (fs.existsSync(metroConfig)) bundleArgs.push('--config', metroConfig);
process.exit(0);
}
if (command === 'autolink') {
autolink({ projectRoot, configFile: option('--config-json') });
process.exit(0);
}
if (command === 'prebuilt') {
const appRequire = createRequire(path.join(projectRoot, 'package.json'));
let gpuiPackageJson;
try {
gpuiPackageJson = appRequire.resolve(
'@react-native-gpui/core/package.json',
);
} catch {
console.error(
'@react-native-gpui/core is not installed in this project; run `npm install @react-native-gpui/core`.',
);
process.exit(1);
}
const script = path.join(
path.dirname(gpuiPackageJson),
'scripts',
'prebuilt-binaries.mjs',
);
const subcommand = process.argv[3];
const allowed = ['fetch', 'path', 'verify', 'clean'];
if (!allowed.includes(subcommand)) {
console.error(
`usage: react-native-gpui prebuilt <${allowed.join('|')}> [--triple <triple>] [dir]`,
);
process.exit(1);
}
const forwarded = process.argv.slice(3);
const result = spawnSync(process.execPath, [script, ...forwarded], {
stdio: 'inherit',
});
process.exit(result.status ?? 1);
}
@@ -0,0 +1,249 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/streetwriters/react-native-gpui/packages/cli/desktop.schema.json",
"title": "React Native GPUI desktop app configuration",
"description": "The desktop/desktop.json file of a React Native GPUI app. Every field is optional: an app with no desktop.json runs and packages with the defaults.",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"description": "Path to this schema, for editor completion.",
"type": "string"
},
"productName": {
"description": "The name a person sees: window title bar, Dock, menu bar, installers. Defaults to the app's displayName, else its name.",
"type": "string",
"minLength": 1
},
"identifier": {
"description": "The reverse-DNS bundle id, e.g. com.example.myapp. Defaults to an id derived from the app name.",
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9.-]*$"
},
"version": {
"description": "The app version. Defaults to the version in package.json.",
"type": "string",
"minLength": 1
},
"icon": {
"description": "One square PNG, recommended 1024x1024, relative to desktop/. Per-OS icon formats are generated from it. When this is unset the CLI uses its own placeholder, so every packaging format can be built.",
"type": "string",
"minLength": 1
},
"window": {
"description": "The main window.",
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"description": "The title bar text. Defaults to productName.",
"type": "string"
},
"width": {
"description": "The window width in points. Defaults to 1024.",
"type": "number",
"exclusiveMinimum": 0
},
"height": {
"description": "The window height in points. Defaults to 768.",
"type": "number",
"exclusiveMinimum": 0
},
"position": {
"description": "Where the window opens. Omit to let the platform place it.",
"type": "object",
"additionalProperties": false,
"required": ["x", "y"],
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
}
}
},
"resizable": {
"description": "Whether the window can be resized. Defaults to true.",
"type": "boolean"
},
"restoreState": {
"description": "Whether the window remembers its size and position between launches. Defaults to true.",
"type": "boolean"
},
"titleBar": {
"description": "How the window's title bar is drawn. 'default' keeps the platform's; 'transparent' keeps the window's chrome but lets the app draw under it, which on macOS leaves the traffic lights over the app's own bar; 'hidden' removes it entirely, and the app must draw and move its own bar. On Linux 'transparent' and 'hidden' both ask the compositor for client-side decorations, and a window manager that refuses leaves the native title bar in place. Defaults to 'default'.",
"default": "default",
"oneOf": [
{
"type": "string",
"enum": ["default"]
},
{
"type": "string",
"enum": ["transparent"]
},
{
"type": "string",
"enum": ["hidden"]
}
]
},
"trafficLightPosition": {
"description": "Where the macOS traffic lights sit inside a transparent or hidden title bar, in points from the window's top-left corner. Ignored on Windows and Linux, which have no traffic lights. Defaults to the platform's own position.",
"type": "object",
"additionalProperties": false,
"required": ["x", "y"],
"properties": {
"x": {
"type": "number",
"minimum": 0
},
"y": {
"type": "number",
"minimum": 0
}
}
},
"menuBar": {
"description": "Whether the main window shows a menu bar. Windows and Linux draw one inside the window; on macOS this does nothing, because the bar belongs to the system. Defaults to true.",
"type": "boolean"
}
}
},
"devMenu": {
"description": "Whether the development menu is available. Defaults to true in development builds.",
"type": "boolean"
},
"tray": {
"description": "A menu-bar or notification-area icon for the app.",
"type": "object",
"additionalProperties": false,
"properties": {
"component": {
"description": "The React component to render as the tray popover.",
"type": "string",
"minLength": 1
},
"tooltip": {
"description": "The tooltip text.",
"type": "string"
},
"icon": {
"description": "The tray icon, a PNG or JPEG path relative to desktop/. Without one the tray shows a built-in glyph.",
"type": "string",
"minLength": 1
},
"iconTemplate": {
"description": "macOS: draw the icon as a template the menu bar tints for light and dark mode. Use it for a monochrome glyph. Defaults to false.",
"type": "boolean"
},
"accessory": {
"description": "Whether the app runs as an accessory, with no Dock icon. Defaults to true.",
"type": "boolean"
}
}
},
"bundle": {
"description": "How the app is packaged.",
"type": "object",
"additionalProperties": false,
"properties": {
"targets": {
"description": "The formats to build. 'all' means every alpha format for the current OS.",
"oneOf": [
{
"type": "string",
"enum": ["all"]
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["app", "dmg", "nsis", "deb", "appimage"]
}
}
]
},
"resources": {
"description": "Extra files to ship, relative to desktop/.",
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"copyright": {
"description": "The copyright line shown by installers.",
"type": "string"
},
"category": {
"description": "The app category, for the Linux desktop entry.",
"type": "string"
},
"macos": {
"type": "object",
"additionalProperties": false,
"properties": {
"minimumSystemVersion": {
"description": "The oldest macOS the app supports. Defaults to 13.0.",
"type": "string"
},
"entitlements": {
"description": "A path to an entitlements plist, relative to desktop/.",
"type": ["string", "null"]
},
"infoPlist": {
"description": "Extra keys merged into the generated Info.plist.",
"type": "object"
}
}
},
"windows": {
"type": "object",
"additionalProperties": false,
"properties": {
"nsis": {
"type": "object",
"additionalProperties": false,
"properties": {
"installMode": {
"description": "Whether the installer installs for the current user or for everyone.",
"type": "string",
"enum": ["currentUser", "perMachine", "both"]
}
}
}
}
},
"linux": {
"type": "object",
"additionalProperties": false,
"properties": {
"deb": {
"type": "object",
"additionalProperties": false,
"properties": {
"depends": {
"description": "Extra Debian package dependencies.",
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
},
"appimage": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@react-native-gpui/cli",
"version": "0.1.0",
"description": "React Native CLI integration and project scaffolding for the GPUI desktop platform.",
"files": [
"bin",
"src",
"templates",
"assets",
"react-native.config.cjs",
"desktop.schema.json"
],
"bin": {
"react-native-gpui": "./bin/react-native-gpui.js"
},
"type": "module",
"dependencies": {}
}
@@ -0,0 +1,207 @@
module.exports = {
commands: [
{
name: 'run-desktop',
description:
'Run the GPUI desktop target with Metro or as a release build',
func: async (_argv, _config, options) => {
const { runDesktop } = await import('./src/commands.js');
await runDesktop({
projectRoot: options.projectRoot,
entryFile: options.entryFile,
bundle: options.bundle,
config: options.config,
release: options.release,
port: options.port,
host: options.host,
features: options.features,
noLaunch: options.noLaunch === true || options.launch === false,
packager: options.packager !== false && options.noPackager !== true,
packagerLog: options.packagerLog === true,
terminal: options.terminal,
});
},
options: [
{ name: '--entry-file <path>', description: 'Application entry file' },
{
name: '--release',
description: 'Bundle JavaScript and build the optimized app',
},
{
name: '--port <number>',
description: 'Metro server port (default: 8081)',
},
{
name: '--host <host>',
description: 'Metro server host (default: localhost)',
},
{ name: '--config <path>', description: 'Metro configuration file' },
{ name: '--bundle <path>', description: 'Bundle output path' },
{
name: '--features <features>',
description: 'Comma-separated Cargo features for the desktop app',
},
{
name: '--no-launch',
description: 'Build the desktop app without launching it',
},
{
name: '--no-packager',
description: 'Do not start Metro if it is not already running',
},
{
name: '--terminal <app>',
description:
'Terminal app Metro opens in (default: $REACT_TERMINAL, then the platform default)',
},
{
name: '--packager-log',
description:
'Run Metro in the background, logging to desktop/build/metro.log',
},
],
examples: [
{
desc: 'Run with Metro and Fast Refresh',
cmd: 'npx react-native run-desktop',
},
{
desc: 'Build a bundled release',
cmd: 'npx react-native run-desktop --release',
},
],
},
{
name: 'package-desktop',
description:
'Build installable packages for this OS from desktop/desktop.json',
func: async (_argv, _config, options) => {
const { packageDesktop } = await import('./src/commands.js');
await packageDesktop({
projectRoot: options.projectRoot,
targets: options.target,
outDir: options.out,
});
},
options: [
{
name: '--target <formats>',
description:
'Comma-separated formats: app, dmg, nsis, deb, appimage (default: all for this OS)',
},
{
name: '--project-root <path>',
description: 'React Native project directory',
},
{ name: '--out <dir>', description: 'Output directory' },
],
examples: [
{
desc: 'Build every format this OS can produce',
cmd: 'npx react-native package-desktop',
},
{
desc: 'Build only a dmg',
cmd: 'npx react-native package-desktop --target dmg',
},
],
},
{
name: 'init-desktop',
description: 'Add GPUI desktop support to an existing React Native app',
func: async (_argv, _config, options) => {
const { initDesktop } = await import('./src/commands.js');
initDesktop({
projectRoot: options.projectRoot,
skipInstall: options.skipInstall,
});
},
options: [
{
name: '--project-root <path>',
description: 'React Native project directory',
},
{
name: '--skip-install',
description: 'Write the scaffold without installing packages',
},
],
examples: [
{
desc: 'Add desktop support to this app',
cmd: 'npx react-native init-desktop',
},
],
},
{
name: 'create-library <name>',
description: 'Create a GPUI TurboModule library in a new directory',
func: async (argv, _config, options) => {
const { createLibrary } = await import('./src/commands.js');
createLibrary({
libraryName: argv[0],
type: options.type,
destination: options.directory,
moduleName: options.moduleName,
packageName: options.packageName,
});
},
options: [
{
name: '--type <rust|cpp>',
description: 'Native implementation language (default: rust)',
},
{ name: '--directory <path>', description: 'Output directory' },
{
name: '--module-name <name>',
description: 'Native TurboModule name',
},
{ name: '--package-name <name>', description: 'npm package name' },
],
examples: [
{
desc: 'Create a Rust TurboModule package',
cmd: 'npx react-native create-library AwesomeStorage',
},
{
desc: 'Create a C++ TurboModule package',
cmd: 'npx react-native create-library AwesomeStorage --type cpp',
},
],
},
{
name: 'collect-node-addons',
description:
'Copy the Node addons a bundle loads next to a packaged app executable',
func: async (_argv, _config, options) => {
const { collectNodeAddons } = await import('./src/commands.js');
collectNodeAddons({
bundle: options.bundle,
out: options.out,
platform: options.platform,
});
},
options: [
{
name: '--bundle <path>',
description: 'The bundle file to scan for _requireNodeLibrary calls',
},
{
name: '--out <dir>',
description: 'Directory to copy the addons into',
},
{
name: '--platform <platform>',
description:
'Target platform for shared-library matching (default: the host)',
},
],
examples: [
{
desc: 'Collect the addons a release bundle loads',
cmd: 'npx react-native collect-node-addons --bundle build/index.bundle --out build/node-addons',
},
],
},
],
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,697 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, expect, test } from 'vitest';
import { autolink } from './autolink.js';
// The repository root by lookup, not by counting parents: this file sits at a
// different depth before and after a folder move.
const repoRoot = (() => {
let dir = path.dirname(fileURLToPath(import.meta.url));
for (;;) {
const manifest = path.join(dir, 'package.json');
if (fs.existsSync(manifest)) {
try {
if (JSON.parse(fs.readFileSync(manifest, 'utf8')).workspaces)
return dir;
} catch {}
}
const parent = path.dirname(dir);
if (parent === dir) throw new Error('no workspace root above the test');
dir = parent;
}
})();
const temporaryRoots = [];
const spec = `import { TurboModuleRegistry, type TurboModule } from 'react-native';
export interface Spec extends TurboModule {
greet(name: string): string;
loadLater(name: string): Promise<boolean>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('Fake');
`;
/**
* A project small enough for autolink to run end to end: a manifest to resolve
* from, a desktop Cargo manifest, one JS engine, and one C++ TurboModule
* package. It lives under the repo so Node resolves the workspace packages.
*/
function createProject({
withCodegenConfig = true,
duplicateLibrary = false,
project,
} = {}) {
const projectRoot = fs.mkdtempSync(path.join(repoRoot, '.local/autolink-'));
temporaryRoots.push(projectRoot);
const write = (relative, contents) => {
const target = path.join(projectRoot, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
};
write(
'package.json',
`${JSON.stringify({ name: 'autolink-test-app', version: '0.0.1' }, null, 2)}\n`,
);
write(
'desktop/Cargo.toml',
'[package]\nname = "autolink-test-app"\nversion = "0.0.1"\nedition = "2021"\n\n[dependencies]\n',
);
write(
'node_modules/fake-engine/package.json',
`${JSON.stringify({ name: 'fake-engine', version: '0.0.1' }, null, 2)}\n`,
);
const moduleManifest = {
name: 'fake-module',
version: '0.0.1',
...(withCodegenConfig
? {
codegenConfig: {
name: 'FakeSpec',
type: 'modules',
jsSrcsDir: 'src',
},
}
: {}),
};
write(
'node_modules/fake-module/package.json',
`${JSON.stringify(moduleManifest, null, 2)}\n`,
);
write('node_modules/fake-module/src/NativeFake.ts', spec);
write(
'node_modules/fake-module/desktop/CMakeLists.txt',
'set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES "${CMAKE_CURRENT_LIST_DIR}/cpp/FakeModule.cpp")\n',
);
write('node_modules/fake-module/cpp/FakeModule.cpp', '// fake module\n');
write(
'node_modules/fake-module/react-native.config.js',
"module.exports = {dependency: {platforms: {desktop: {cmake: 'desktop', cppProvider: 'create_fake_module'}}}};\n",
);
if (duplicateLibrary) {
write(
'node_modules/fake-module-two/package.json',
`${JSON.stringify(
{
name: 'fake-module-two',
version: '0.0.1',
codegenConfig: {
name: 'FakeSpec',
type: 'modules',
jsSrcsDir: 'src',
},
},
null,
2,
)}\n`,
);
write('node_modules/fake-module-two/src/NativeFakeTwo.ts', spec);
write(
'node_modules/fake-module-two/react-native.config.js',
"module.exports = {dependency: {platforms: {desktop: {cppProvider: 'create_fake_two_module'}}}};\n",
);
}
const configPath = path.join(projectRoot, 'rn-config.json');
fs.writeFileSync(
configPath,
`${JSON.stringify(
{
...(project ? { project: { desktop: project } } : {}),
dependencies: {
'fake-engine': {
root: path.join(projectRoot, 'node_modules/fake-engine'),
platforms: {
desktop: {
jsEngine: {
name: 'hermes',
register: 'rn_gpui_register_js_engine_hermes',
},
},
},
},
'fake-module': {
root: path.join(projectRoot, 'node_modules/fake-module'),
platforms: {
desktop: { cmake: 'desktop', cppProvider: 'create_fake_module' },
},
},
...(duplicateLibrary
? {
'fake-module-two': {
root: path.join(projectRoot, 'node_modules/fake-module-two'),
platforms: {
desktop: { cppProvider: 'create_fake_two_module' },
},
},
}
: {}),
},
},
null,
2,
)}\n`,
);
return { projectRoot, configPath };
}
afterAll(() => {
for (const root of temporaryRoots) {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('generates both spec headers for a C++ TurboModule package', () => {
const { projectRoot, configPath } = createProject();
autolink({ projectRoot, configFile: configPath });
const codegenDirectory = path.join(
projectRoot,
'desktop/autolink/codegen/FakeSpec',
);
const ours = fs.readFileSync(
path.join(codegenDirectory, 'FakeSpec.h'),
'utf8',
);
const reactNative = fs.readFileSync(
path.join(codegenDirectory, 'FakeSpecJSI.h'),
'utf8',
);
expect(ours).toContain('#include <FakeSpecJSI.h>');
expect(ours).toContain(
'class NativeFakeSpec : public rngpui::TurboModuleBase<T>',
);
expect(ours).toContain('kModuleName = "Fake"');
expect(ours).toContain('this->template valueMethod<&T::greet>("greet");');
expect(ours).toContain(
'this->template promiseMethod<&T::loadLater>("loadLater");',
);
expect(reactNative).toContain('NativeFakeCxxSpec');
expect(reactNative).toContain('"Fake"');
const cmake = fs.readFileSync(
path.join(projectRoot, 'desktop/autolink/autolink.cmake'),
'utf8',
);
const includeDirs = cmake
.split('\n')
.find(line => line.includes('RN_GPUI_APP_CPP_INCLUDE_DIRS'));
// The generated CMake normalises every path to `/`, which is what CMake
// accepts on every platform (a Windows `\` is an escape in a CMake string).
expect(includeDirs).toContain(codegenDirectory.replaceAll('\\', '/'));
});
const manifestOf = projectRoot =>
JSON.parse(
fs.readFileSync(
path.join(projectRoot, 'desktop/autolink/autolink.json'),
'utf8',
),
);
test('writes the prebuilt mode into the manifest, defaulting to auto', () => {
const defaults = createProject();
autolink({
projectRoot: defaults.projectRoot,
configFile: defaults.configPath,
});
expect(manifestOf(defaults.projectRoot).prebuilt).toBe('auto');
const always = createProject({ project: { prebuilt: 'always' } });
autolink({ projectRoot: always.projectRoot, configFile: always.configPath });
expect(manifestOf(always.projectRoot).prebuilt).toBe('always');
});
test('throws on an unknown prebuilt mode', () => {
const { projectRoot, configPath } = createProject({
project: { prebuilt: true },
});
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
"project.desktop.prebuilt must be one of 'auto', 'always', 'never'",
);
});
test('reads project.desktop without complaint', () => {
const { projectRoot, configPath } = createProject({
project: { prebuilt: 'never' },
});
autolink({ projectRoot, configFile: configPath });
expect(manifestOf(projectRoot).prebuilt).toBe('never');
});
test('throws when a C++ TurboModule package has no codegenConfig.name', () => {
const { projectRoot, configPath } = createProject({
withCodegenConfig: false,
});
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
'fake-module: codegenConfig.name is required for a C++ TurboModule',
);
});
test('throws when two packages share one codegenConfig.name', () => {
const { projectRoot, configPath } = createProject({ duplicateLibrary: true });
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
'fake-module-two: codegenConfig.name FakeSpec is already used by fake-module',
);
});
test('leaves both headers untouched when nothing changed', () => {
const { projectRoot, configPath } = createProject();
autolink({ projectRoot, configFile: configPath });
const codegenDirectory = path.join(
projectRoot,
'desktop/autolink/codegen/FakeSpec',
);
const headers = ['FakeSpec.h', 'FakeSpecJSI.h'].map(name =>
path.join(codegenDirectory, name),
);
const before = headers.map(header => fs.statSync(header).mtimeMs);
autolink({ projectRoot, configFile: configPath });
const after = headers.map(header => fs.statSync(header).mtimeMs);
expect(after).toEqual(before);
});
test('registers a crate that has no TurboModule specs', () => {
const { projectRoot, configPath } = createProject();
const crateRoot = path.join(projectRoot, 'node_modules/fake-paint');
fs.mkdirSync(path.join(crateRoot, 'desktop/src'), { recursive: true });
fs.writeFileSync(
path.join(crateRoot, 'package.json'),
`${JSON.stringify({ name: 'fake-paint', version: '0.0.1' }, null, 2)}\n`,
);
fs.writeFileSync(
path.join(crateRoot, 'desktop/Cargo.toml'),
'[package]\nname = "fake-paint"\nversion = "0.0.1"\nedition = "2021"\n\n[dependencies]\n',
);
fs.writeFileSync(
path.join(crateRoot, 'react-native.config.js'),
"module.exports = {dependency: {platforms: {desktop: {crate: 'fake-paint'}}}};\n",
);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.dependencies['fake-paint'] = {
root: crateRoot,
platforms: { desktop: { crate: 'fake-paint' } },
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
autolink({ projectRoot, configFile: configPath });
const generated = fs.readFileSync(
path.join(projectRoot, 'desktop/autolink/src/lib.rs'),
'utf8',
);
expect(generated).toContain('fake_paint::rn_gpui_register();');
});
/**
* A project with one Rust module crate, so the manifest checks have something to
* read. The crate's manifest is the argument: the tests below vary only that.
*/
function projectWithRustCrate(manifest) {
// No codegen config: `fake-module` declares a `cppProvider`, and a C++
// TurboModule without a `codegenConfig.name` is refused before the manifest
// checks this fixture is about would run.
const { projectRoot, configPath } = createProject({
withCodegenConfig: false,
});
const config0 = JSON.parse(fs.readFileSync(configPath, 'utf8'));
delete config0.dependencies['fake-module'];
fs.writeFileSync(configPath, `${JSON.stringify(config0, null, 2)}\n`);
const crateRoot = path.join(projectRoot, 'node_modules/fake-rust');
fs.mkdirSync(path.join(crateRoot, 'desktop'), { recursive: true });
fs.writeFileSync(
path.join(crateRoot, 'package.json'),
`${JSON.stringify({ name: 'fake-rust', version: '0.0.1' }, null, 2)}\n`,
);
fs.writeFileSync(path.join(crateRoot, 'desktop/Cargo.toml'), manifest);
fs.writeFileSync(
path.join(crateRoot, 'react-native.config.js'),
"module.exports = {dependency: {platforms: {desktop: {crate: 'fake-rust'}}}};\n",
);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.dependencies['fake-rust'] = {
root: crateRoot,
platforms: { desktop: { crate: 'fake-rust' } },
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
return { projectRoot, configPath, crateRoot };
}
const CARGO_HEADER =
'[package]\nname = "fake-rust"\nversion = "0.0.1"\nedition = "2021"\n\n[dependencies]\n';
test('the app manifest gets a managed patch block pointing at core', () => {
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\n`,
);
autolink({ projectRoot, configFile: configPath });
const appCargo = fs.readFileSync(
path.join(projectRoot, 'desktop/Cargo.toml'),
'utf8',
);
expect(appCargo).toContain('[patch.crates-io]');
expect(appCargo).toContain(
'# Managed by @react-native-gpui/cli autolink; do not edit between these markers.',
);
expect(appCargo).toMatch(
/^rn-gpui-module = \{ path = "[^"]+rn-gpui-module" \}$/mu,
);
expect(appCargo).toMatch(
/^react-native-gpui = \{ path = "[^"]+rn-gpui" \}$/mu,
);
// The rev must be core's, read from core's own manifest, not a copy.
const coreManifest = fs.readFileSync(
path.join(repoRoot, 'packages/core/ReactGpui/Cargo.toml'),
'utf8',
);
const rev = /^gpui = \{[^}]*rev = "([^"]+)"/mu.exec(coreManifest)?.[1];
expect(rev).toBeDefined();
expect(appCargo).toContain(
`gpui = { git = "https://github.com/streetwriters/gpui-fast", rev = "${rev}" }`,
);
});
test('a second autolink leaves the app manifest byte-identical', () => {
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\n`,
);
autolink({ projectRoot, configFile: configPath });
const manifest = path.join(projectRoot, 'desktop/Cargo.toml');
const once = fs.readFileSync(manifest, 'utf8');
autolink({ projectRoot, configFile: configPath });
expect(fs.readFileSync(manifest, 'utf8')).toBe(once);
});
test('an unrelated user patch table is kept, with one header', () => {
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\n`,
);
const manifest = path.join(projectRoot, 'desktop/Cargo.toml');
const original = fs.readFileSync(manifest, 'utf8');
fs.writeFileSync(
manifest,
`${original}\n[patch.crates-io]\nserde = { path = "vendor/serde" }\n`,
);
autolink({ projectRoot, configFile: configPath });
const patched = fs.readFileSync(manifest, 'utf8');
expect(patched).toContain('serde = { path = "vendor/serde" }');
expect(patched.match(/^\[patch\.crates-io\]$/gmu)).toHaveLength(1);
expect(patched).toContain('rn-gpui-module = { path =');
});
test('a user patch table that claims a managed key is refused', () => {
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\n`,
);
const manifest = path.join(projectRoot, 'desktop/Cargo.toml');
const original = fs.readFileSync(manifest, 'utf8');
fs.writeFileSync(
manifest,
`${original}\n[patch.crates-io]\ngpui = { path = "my-gpui" }\n`,
);
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
/already sets gpui/u,
);
});
test('a module crate may not name core by path', () => {
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = { path = "../../core/ReactGpui/rn-gpui-module" }\n`,
);
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
/must be a version requirement/u,
);
});
test('a module crate may not pin gpui by git', () => {
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\ngpui = { git = "https://github.com/streetwriters/gpui-fast", rev = "c380367" }\n`,
);
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
/must be a version requirement/u,
);
});
test('a gpui requirement the patch does not satisfy is refused', () => {
// 0.3 would resolve to the crates.io crate instead, and the only sign would be
// one warning Cargo prints and nobody reads.
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\ngpui = "0.3"\n`,
);
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
/does not match the 0\.2\.2/u,
);
});
test('a managed crate in table form is checked too', () => {
// `[dependencies.gpui]` with its keys on the following lines is legal TOML, so
// it must not be a way past the path check.
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\n\n[dependencies.gpui]\ngit = "https://github.com/streetwriters/gpui-fast"\nrev = "c380367"\n`,
);
expect(() => autolink({ projectRoot, configFile: configPath })).toThrow(
/must be a version requirement/u,
);
});
test('a CRLF lockfile still yields the managed block', () => {
// A Windows checkout with core.autocrlf gives these files CRLF endings. The
// lockfile pattern anchors on `\n`, so without normalising the read it finds no
// gpui entry and autolink throws on every Windows run. The manifest pattern
// survives CRLF on its own, because `\s*$` treats `\r` as whitespace.
const { projectRoot, configPath } = projectWithRustCrate(
`${CARGO_HEADER}rn-gpui-module = "0.1"\n`,
);
const coreLock = path.join(repoRoot, 'packages/core/ReactGpui/Cargo.lock');
const original = fs.readFileSync(coreLock, 'utf8');
fs.writeFileSync(coreLock, original.replace(/\n/gu, '\r\n'));
try {
autolink({ projectRoot, configFile: configPath });
const rev = /^gpui = \{[^}]*rev = "([^"]+)"/mu.exec(
fs.readFileSync(
path.join(repoRoot, 'packages/core/ReactGpui/Cargo.toml'),
'utf8',
),
)?.[1];
expect(
fs.readFileSync(path.join(projectRoot, 'desktop/Cargo.toml'), 'utf8'),
).toContain(`rev = "${rev}"`);
} finally {
fs.writeFileSync(coreLock, original);
}
});
/**
* Adds a package that declares host components: a codegen spec for each, a Rust
* crate, and `hostComponents` entries in the platform config.
*/
function addHostComponentPackage(
{ projectRoot, configPath },
{
name = 'fake-host',
library = 'FakeHostSpec',
components,
interfaceOnly = true,
},
) {
const root = path.join(projectRoot, 'node_modules', name);
const write = (relative, contents) => {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
};
write(
'package.json',
`${JSON.stringify(
{
name,
version: '0.0.1',
codegenConfig: { name: library, type: 'components', jsSrcsDir: 'src' },
},
null,
2,
)}\n`,
);
write(
'desktop/Cargo.toml',
`[package]\nname = "${name}"\nversion = "0.0.1"\nedition = "2021"\n\n[dependencies]\n`,
);
for (const component of components) {
write(
`src/${component.name}NativeComponent.ts`,
`import { codegenNativeComponent, type ViewProps } from 'react-native';
export interface NativeProps extends ViewProps {
title?: string;
count?: Int32;
}
export default codegenNativeComponent<NativeProps>('${component.name}'${
interfaceOnly ? ', { interfaceOnly: true }' : ''
});
`,
);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.dependencies[name] = {
root,
platforms: {
desktop: {
crate: name,
hostComponents: components,
},
},
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
return root;
}
const generatedLib = projectRoot =>
fs.readFileSync(
path.join(projectRoot, 'desktop/autolink/src/lib.rs'),
'utf8',
);
const generatedProvider = projectRoot =>
fs.readFileSync(
path.join(projectRoot, 'desktop/autolink/CppTurboModules.generated.cpp'),
'utf8',
);
test('declares each host component with its index and layout flags', () => {
const project = createProject();
addHostComponentPackage(project, {
components: [
{ name: 'FakeChart' },
{ name: 'FakeLabel', measured: true },
{ name: 'FakeCard', container: true, nativeSurface: true },
],
});
autolink({
projectRoot: project.projectRoot,
configFile: project.configPath,
});
const lib = generatedLib(project.projectRoot);
expect(lib).toContain(
'declare_host_component(0, "FakeChart", react_native_gpui::host_component::HostComponentOptions { measured: false, native_surface: false, container: false });',
);
expect(lib).toContain(
'declare_host_component(1, "FakeLabel", react_native_gpui::host_component::HostComponentOptions { measured: true, native_surface: false, container: false });',
);
expect(lib).toContain(
'declare_host_component(2, "FakeCard", react_native_gpui::host_component::HostComponentOptions { measured: false, native_surface: true, container: true });',
);
// Declared before any package registers, then checked for code afterwards.
expect(lib.indexOf('declare_host_components();')).toBeLessThan(
lib.indexOf('fake_host::rn_gpui_register();'),
);
expect(lib.indexOf('fake_host::rn_gpui_register();')).toBeLessThan(
lib.indexOf('ensure_host_components();'),
);
expect(lib).toContain('expect_host_component(2, "FakeCard");');
});
test('generates the C++ that registers a host component library and calls it at startup', () => {
const project = createProject();
addHostComponentPackage(project, {
components: [{ name: 'FakeChart' }, { name: 'FakeCard', container: true }],
});
autolink({
projectRoot: project.projectRoot,
configFile: project.configPath,
});
const generated = path.join(
project.projectRoot,
'desktop/autolink/codegen/FakeHostSpec/react/renderer/components/FakeHostSpec/GpuiHostComponents.generated.cpp',
);
const cpp = fs.readFileSync(generated, 'utf8');
expect(cpp).toContain('entry.index = 0;');
expect(cpp).toContain('entry.index = 1;');
expect(cpp).toContain('entry.container = true;');
expect(cpp).toContain(
'extern "C" void rn_gpui_register_host_components_FakeHostSpec() {',
);
// The app calls it explicitly: a static library member nothing references is
// dropped by the linker, and its static initialiser with it.
const provider = generatedProvider(project.projectRoot);
expect(provider).toContain(
'extern "C" void rn_gpui_register_host_components_FakeHostSpec();',
);
expect(provider).toContain(
' rn_gpui_register_host_components_FakeHostSpec();',
);
// The generated file is compiled with React Native's own output for the spec.
const cmake = fs.readFileSync(
path.join(project.projectRoot, 'desktop/autolink/autolink.cmake'),
'utf8',
);
expect(cmake).toContain('GpuiHostComponents.generated.cpp');
expect(cmake).toContain('Props.cpp');
});
test('numbers host components across packages in one global order', () => {
const project = createProject();
addHostComponentPackage(project, {
name: 'fake-host',
library: 'FakeHostSpec',
components: [{ name: 'FakeChart' }],
});
addHostComponentPackage(project, {
name: 'fake-host-two',
library: 'FakeHostTwoSpec',
components: [{ name: 'FakeOther' }],
});
autolink({
projectRoot: project.projectRoot,
configFile: project.configPath,
});
const lib = generatedLib(project.projectRoot);
expect(lib).toContain('declare_host_component(0, "FakeChart"');
expect(lib).toContain('declare_host_component(1, "FakeOther"');
});
test('refuses a host component that is both measured and a container', () => {
const project = createProject();
addHostComponentPackage(project, {
components: [{ name: 'FakeBoth', measured: true, container: true }],
});
expect(() =>
autolink({
projectRoot: project.projectRoot,
configFile: project.configPath,
}),
).toThrow(/cannot be both "measured" and a "container"/u);
});
test('refuses a host component whose spec is not interfaceOnly', () => {
const project = createProject();
addHostComponentPackage(project, {
interfaceOnly: false,
components: [{ name: 'FakeChart' }],
});
expect(() =>
autolink({
projectRoot: project.projectRoot,
configFile: project.configPath,
}),
).toThrow(
/must be created with codegenNativeComponent\(\.\.\., \{ interfaceOnly: true \}\)/u,
);
});
test('refuses a duplicate host component name across packages', () => {
const project = createProject();
addHostComponentPackage(project, {
name: 'fake-host',
library: 'FakeHostSpec',
components: [{ name: 'FakeChart' }],
});
addHostComponentPackage(project, {
name: 'fake-host-two',
library: 'FakeHostTwoSpec',
components: [{ name: 'FakeChart' }],
});
expect(() =>
autolink({
projectRoot: project.projectRoot,
configFile: project.configPath,
}),
).toThrow(/host component FakeChart is already declared by fake-host/u);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,469 @@
import fs from 'node:fs';
import http from 'node:http';
import nodePath from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, expect, test } from 'vitest';
import {
collectNodeAddons,
createLibrary,
desktopLaunchConfig,
linuxBuildDependencyProblem,
metroProjectRoot,
metroTerminal,
scanNodeAddonCalls,
scaffoldDesktop,
} from './commands.js';
// The repository root by lookup, not by counting parents: this file sits at a
// different depth before and after a folder move.
const repoRoot = (() => {
let dir = nodePath.dirname(fileURLToPath(import.meta.url));
for (;;) {
const manifest = nodePath.join(dir, 'package.json');
if (fs.existsSync(manifest)) {
try {
if (JSON.parse(fs.readFileSync(manifest, 'utf8')).workspaces)
return dir;
} catch {}
}
const parent = nodePath.dirname(dir);
if (parent === dir) throw new Error('no workspace root above the test');
dir = parent;
}
})();
const temporaryRoots = [];
afterAll(() => {
for (const root of temporaryRoots) {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('the Linux preflight accepts a complete system', () => {
expect(linuxBuildDependencyProblem('linux', () => true)).toBeNull();
});
test('the Linux preflight names the missing module and its package', () => {
const problem = linuxBuildDependencyProblem('linux', pc => pc !== 'xcb');
expect(problem).toContain('dependency xcb was not found');
expect(problem).toContain('libxcb1-dev');
expect(problem).toContain('PKG_CONFIG_PATH');
});
test('the Linux preflight gives one package name per distribution', () => {
const problem = linuxBuildDependencyProblem(
'linux',
pc => pc !== 'xkbcommon',
);
expect(problem).toContain('Debian/Ubuntu: sudo apt install libxkbcommon-dev');
expect(problem).toContain('Fedora: sudo dnf install libxkbcommon-devel');
expect(problem).toContain('Arch: sudo pacman -S libxkbcommon');
});
test('the Linux preflight prints the computed system pkg-config path', () => {
const path = '/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig';
const problem = linuxBuildDependencyProblem(
'linux',
() => false,
() => path,
);
expect(problem).toContain(`PKG_CONFIG_PATH=${path}`);
});
test('the Linux preflight falls back when the system path is unknown', () => {
const problem = linuxBuildDependencyProblem(
'linux',
() => false,
() => null,
);
expect(problem).not.toContain('PKG_CONFIG_PATH=');
expect(problem).toContain('point PKG_CONFIG_PATH at your system pkgconfig');
});
test('the Linux preflight reports a missing pkg-config', () => {
expect(
linuxBuildDependencyProblem('linux', () => 'missing-pkg-config'),
).toContain('pkg-config is required');
});
test('the Linux preflight is a no-op on other platforms', () => {
expect(linuxBuildDependencyProblem('darwin', () => false)).toBeNull();
expect(linuxBuildDependencyProblem('win32', () => false)).toBeNull();
});
// --- collect-node-addons -----------------------------------------------------
test('the scanner reads every key/path pair a bundle emits', () => {
const bundle = [
'var a = _requireNodeLibrary("node_modules/pkg/prebuilds/x.node", "/app/node_modules/pkg/prebuilds/x.node");',
'var b = _requireNodeLibrary(\n "local/deadbeef/y.node",\n "/app/build/Release/y.node"\n);',
].join('\n');
expect(scanNodeAddonCalls(bundle)).toEqual([
{
key: 'node_modules/pkg/prebuilds/x.node',
path: '/app/node_modules/pkg/prebuilds/x.node',
},
{ key: 'local/deadbeef/y.node', path: '/app/build/Release/y.node' },
]);
});
test('the scanner reports one entry per key', () => {
const bundle =
'_requireNodeLibrary("a/x.node","/p/x.node");_requireNodeLibrary("a/x.node","/p/x.node");';
expect(scanNodeAddonCalls(bundle)).toHaveLength(1);
});
test('the scanner ignores a call with only a key', () => {
expect(scanNodeAddonCalls('_requireNodeLibrary("a/x.node")')).toEqual([]);
});
test('the scanner unescapes the path Babel emitted', () => {
const bundle = String.raw`_requireNodeLibrary("local/1/a.node","C:\\app\\a.node");`;
expect(scanNodeAddonCalls(bundle)[0].path).toBe('C:\\app\\a.node');
});
test('collect-node-addons copies each addon to <out>/<key>', () => {
const root = fs.mkdtempSync(nodePath.join(repoRoot, '.local/collect-'));
temporaryRoots.push(root);
const addonPath = nodePath.join(root, 'build/Release/addon.node');
fs.mkdirSync(nodePath.dirname(addonPath), { recursive: true });
fs.writeFileSync(addonPath, 'binary');
const bundlePath = nodePath.join(root, 'bundle.js');
// The Babel plugin emits the path through `t.stringLiteral`, so it is escaped
// as a JS string literal would be — backslashes and all on Windows.
fs.writeFileSync(
bundlePath,
`_requireNodeLibrary("local/1/addon.node",${JSON.stringify(addonPath)});`,
);
const out = nodePath.join(root, 'node-addons');
const result = collectNodeAddons({ bundle: bundlePath, out });
expect(result.copied).toHaveLength(1);
expect(
fs.readFileSync(nodePath.join(out, 'local/1/addon.node'), 'utf8'),
).toBe('binary');
});
test('collect-node-addons copies sibling shared libraries', () => {
const root = fs.mkdtempSync(nodePath.join(repoRoot, '.local/collect-'));
temporaryRoots.push(root);
const directory = nodePath.join(root, 'build/Release');
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(nodePath.join(directory, 'addon.node'), 'binary');
fs.writeFileSync(nodePath.join(directory, 'libdep.so'), 'shared');
fs.writeFileSync(nodePath.join(directory, 'libdep.so.1'), 'shared');
fs.writeFileSync(nodePath.join(directory, 'unrelated.txt'), 'no');
const bundlePath = nodePath.join(root, 'bundle.js');
fs.writeFileSync(
bundlePath,
`_requireNodeLibrary("local/1/addon.node",${JSON.stringify(nodePath.join(directory, 'addon.node'))});`,
);
const out = nodePath.join(root, 'node-addons');
collectNodeAddons({ bundle: bundlePath, out, platform: 'linux' });
expect(fs.existsSync(nodePath.join(out, 'local/1/libdep.so'))).toBe(true);
expect(fs.existsSync(nodePath.join(out, 'local/1/libdep.so.1'))).toBe(true);
expect(fs.existsSync(nodePath.join(out, 'local/1/unrelated.txt'))).toBe(
false,
);
});
test('collect-node-addons fails when an embedded path is gone', () => {
const root = fs.mkdtempSync(nodePath.join(repoRoot, '.local/collect-'));
temporaryRoots.push(root);
const bundlePath = nodePath.join(root, 'bundle.js');
fs.writeFileSync(
bundlePath,
'_requireNodeLibrary("local/1/gone.node","/nowhere/gone.node");',
);
expect(() =>
collectNodeAddons({ bundle: bundlePath, out: nodePath.join(root, 'out') }),
).toThrow(/no longer exist/u);
});
test('collect-node-addons clears a stale directory when the bundle has none', () => {
const root = fs.mkdtempSync(nodePath.join(repoRoot, '.local/collect-'));
temporaryRoots.push(root);
const bundlePath = nodePath.join(root, 'bundle.js');
fs.writeFileSync(bundlePath, 'var x = 1;');
const out = nodePath.join(root, 'node-addons');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(nodePath.join(out, 'stale'), 'x');
const result = collectNodeAddons({ bundle: bundlePath, out });
expect(result.copied).toEqual([]);
expect(fs.existsSync(out)).toBe(false);
});
test('collect-node-addons requires both arguments', () => {
expect(() => collectNodeAddons({})).toThrow(/--bundle/u);
expect(() => collectNodeAddons({ bundle: 'x' })).toThrow(/--out/u);
});
// The launch chain used to be built from `app.json`'s `desktop` key. It reads
// `desktop/desktop.json` now, and these check that the generated `main.rs` is
// the same for the same options.
test('menuBar: false asks the host for no menu bar, and its absence does not', () => {
const hidden = desktopLaunchConfig(
{ window: { menuBar: false }, devMenu: true },
'desktop/desktop.json',
);
expect(hidden).toContain('.menu_bar(false)');
// The default is a menu bar, so the call is only emitted when it is turned off:
// a chain that always emitted it would be the same thing, but the generated file
// reads better without a line saying what the default already is.
const shown = desktopLaunchConfig(
{ window: { menuBar: true }, devMenu: true },
'desktop/desktop.json',
);
expect(shown).not.toContain('.menu_bar(');
});
test('a custom title bar and the traffic light position reach the launch chain', () => {
const hidden = desktopLaunchConfig(
{
window: {
titleBar: 'hidden',
trafficLightPosition: { x: 14, y: 10 },
},
devMenu: true,
},
'desktop/desktop.json',
);
expect(hidden).toContain(
'.title_bar(react_native_gpui::WindowTitleBar::Hidden)',
);
expect(hidden).toContain('.traffic_light_position(14.0, 10.0)');
const transparent = desktopLaunchConfig(
{ window: { titleBar: 'transparent' }, devMenu: true },
'desktop/desktop.json',
);
expect(transparent).toContain(
'.title_bar(react_native_gpui::WindowTitleBar::Transparent)',
);
// `default` is what an app that says nothing already gets, so it must not add
// a call that only restates it.
const plain = desktopLaunchConfig(
{ window: { titleBar: 'default' }, devMenu: true },
'desktop/desktop.json',
);
expect(plain).not.toContain('.title_bar(');
expect(plain).not.toContain('.traffic_light_position(');
});
test('a traffic light position without both coordinates is rejected', () => {
// The schema says `x` and `y` are both required; a launcher that silently used
// the origin would move the traffic lights to the corner of the window.
expect(() =>
desktopLaunchConfig(
{ window: { trafficLightPosition: { x: 14 } }, devMenu: true },
'desktop/desktop.json',
),
).toThrow(/trafficLightPosition needs x and y/u);
});
test('the launch chain reflects the window, dev menu and tray', () => {
const main = desktopLaunchConfig(
{
window: { width: 900, height: 700, title: 'My App', resizable: true },
devMenu: true,
tray: { component: 'TrayRoot', tooltip: 'Open', accessory: true },
},
'desktop/desktop.json',
);
expect(main).toContain('.window_title("My App")');
expect(main).toContain('.window_size(900.0, 700.0)');
expect(main).toContain('.dev_menu(true)');
expect(main).toContain(
'.tray(TrayConfig::default().component("TrayRoot").tooltip("Open"))',
);
expect(main).toContain('options.is_resizable = true;');
});
test('an app asking to restore nothing and be fixed-size says so', () => {
const main = desktopLaunchConfig(
{
window: {
width: 400,
height: 300,
resizable: false,
restoreState: false,
position: { x: 10, y: 20 },
},
devMenu: false,
},
'desktop/desktop.json',
);
expect(main).toContain('.restore_window_state(false)');
expect(main).toContain('.window_position(10.0, 20.0)');
expect(main).toContain('.dev_menu(false)');
expect(main).toContain('options.is_resizable = false;');
expect(main).not.toContain('.tray(');
});
test('a tray with accessory false disables the accessory mode', () => {
const main = desktopLaunchConfig(
{
window: { width: 1, height: 1 },
devMenu: true,
tray: { accessory: false },
},
'desktop/desktop.json',
);
expect(main).toContain('.tray(TrayConfig::default().accessory(false))');
});
test('create-library writes version requirements, not crate paths', () => {
// A module crate installed from npm has no `../../../packages` above it, so a
// relative path to core cannot work there; `[patch.crates-io]` also cannot
// redirect a `path` dependency. The scaffolder therefore writes a version
// requirement and lets autolink patch it to the installed core.
const outer = fs.mkdtempSync(nodePath.join(repoRoot, '.local', 'scaffold-'));
try {
createLibrary({
libraryName: 'fs',
type: 'rust',
destination: outer,
packageName: '@react-native-gpui/fs',
});
const manifest = fs.readFileSync(
nodePath.join(outer, 'desktop/Cargo.toml'),
'utf8',
);
expect(manifest).toContain('rn-gpui-module = "0.1"');
expect(manifest).toContain('rn-gpui-module-build = "0.1"');
expect(manifest).not.toMatch(/path\s*=/u);
expect(manifest).not.toMatch(/git\s*=/u);
} finally {
fs.rmSync(outer, { recursive: true, force: true });
}
});
test('scaffoldDesktop writes the env file that exposes the desktop types', () => {
// The augmentation is global, and a TypeScript augmentation only applies when
// its declaring file is compiled. This reference is what puts it in the app's
// compilation, so `desktopDragRegion` and `onContextMenu` type-check without an
// import of the desktop entry point.
const root = fs.mkdtempSync(nodePath.join(repoRoot, '.local', 'envtypes-'));
try {
fs.writeFileSync(
nodePath.join(root, 'package.json'),
`${JSON.stringify({ name: 'envapp', dependencies: { 'react-native': '0.87.1' } })}\n`,
);
fs.writeFileSync(
nodePath.join(root, 'app.json'),
`${JSON.stringify({ name: 'EnvApp', displayName: 'EnvApp' })}\n`,
);
scaffoldDesktop(
root,
JSON.parse(fs.readFileSync(nodePath.join(root, 'package.json'), 'utf8')),
);
const envPath = nodePath.join(root, 'react-native-gpui-env.d.ts');
const written = fs.readFileSync(envPath, 'utf8');
expect(written).toContain(
'/// <reference types="@react-native-gpui/core/types" />',
);
expect(written).toContain('Do not edit');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('scaffoldDesktop keeps an app’s edited env file', () => {
// `preserveExisting` is how `init-desktop` runs on an app that already has a
// desktop folder, so an app that added its own references there must keep them.
const root = fs.mkdtempSync(nodePath.join(repoRoot, '.local', 'envkeep-'));
try {
fs.writeFileSync(
nodePath.join(root, 'package.json'),
`${JSON.stringify({ name: 'keepapp', dependencies: { 'react-native': '0.87.1' } })}\n`,
);
fs.writeFileSync(
nodePath.join(root, 'app.json'),
`${JSON.stringify({ name: 'KeepApp', displayName: 'KeepApp' })}\n`,
);
const envPath = nodePath.join(root, 'react-native-gpui-env.d.ts');
const edited = '/// <reference types="custom" />\n';
fs.writeFileSync(envPath, edited);
scaffoldDesktop(
root,
JSON.parse(fs.readFileSync(nodePath.join(root, 'package.json'), 'utf8')),
{ preserveExisting: true },
);
expect(fs.readFileSync(envPath, 'utf8')).toBe(edited);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('Metro opens in the requested terminal, then the platform default', () => {
const none = () => false;
expect(metroTerminal('Warp', { platform: 'darwin', env: {} })).toBe('Warp');
expect(
metroTerminal(undefined, {
platform: 'darwin',
env: { REACT_TERMINAL: 'Ghostty' },
}),
).toBe('Ghostty');
expect(
metroTerminal(undefined, {
platform: 'darwin',
env: { TERM_PROGRAM: 'vscode' },
}),
).toBe('Terminal');
expect(
metroTerminal(undefined, {
platform: 'darwin',
env: { TERM_PROGRAM: 'iTerm.app' },
}),
).toBe('iTerm');
expect(metroTerminal(undefined, { platform: 'win32', env: {} })).toBe(
'cmd.exe',
);
expect(
metroTerminal(undefined, {
platform: 'linux',
env: {},
exists: command => command === 'konsole',
}),
).toBe('konsole');
expect(
metroTerminal(undefined, { platform: 'linux', env: {}, exists: none }),
).toBeUndefined();
});
test('a running Metro reports the project it serves', async () => {
const server = http.createServer((request, response) => {
response.setHeader('X-React-Native-Project-Root', '/some/project');
response.end(
request.url === '/status' ? 'packager-status:running' : 'not found',
);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
try {
expect(await metroProjectRoot('127.0.0.1', port)).toBe('/some/project');
} finally {
await new Promise(resolve => server.close(resolve));
}
expect(await metroProjectRoot('127.0.0.1', port)).toBeUndefined();
});
test('a tray icon in desktop.json is embedded in the launch chain', () => {
const chain = desktopLaunchConfig(
{
tray: { tooltip: 'Tray', icon: 'assets/tray.png', iconTemplate: true },
devMenu: true,
},
'desktop/desktop.json',
);
expect(chain).toContain(
'.icon_bytes(include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/tray.png")))',
);
expect(chain).toContain('.icon_template(true)');
expect(
desktopLaunchConfig({ tray: { tooltip: 'Tray' }, devMenu: true }, 'x'),
).not.toContain('.icon_bytes(');
});
@@ -0,0 +1,397 @@
/**
* `desktop/desktop.json` — the one declarative file that describes a desktop
* app: its name, identifier, version, icon, window, and how it is packaged.
*
* The model is Tauri's `tauri.conf.json`: the app owns the file, it is data
* rather than code, and it ships a JSON schema so editors can complete it.
* `react-native.config.js` keeps only build-tooling options, because it is
* executable JavaScript that nothing can read statically.
*
* Every field is optional. An app with no `desktop.json` still runs and packages
* with the defaults below.
*/
import fs from 'node:fs';
import path from 'node:path';
/** The alpha formats each OS can produce. `all` expands to these. */
export const TARGETS_BY_OS = {
macos: ['app', 'dmg'],
windows: ['nsis'],
linux: ['deb', 'appimage'],
};
/** Every format this phase can produce, on any OS. */
export const ALL_TARGETS = ['app', 'dmg', 'nsis', 'deb', 'appimage'];
/**
* The OS name this module's files and targets use, from a Node platform.
*
* `desktop.macos.json` rather than `desktop.darwin.json`: the file names the
* platform a person would, and `process.platform` uses Node's spelling.
*/
export function desktopOs(platform = process.platform) {
switch (platform) {
case 'darwin':
return 'macos';
case 'win32':
return 'windows';
default:
return platform;
}
}
/**
* The key names that must never appear in a config file.
*
* Signing material comes from the environment (D6). A password, token or
* private key written into a file that gets committed and shipped is a leak
* whether or not it is still valid, so a config carrying one is refused rather
* than warned about.
*/
const SECRET_KEY = /password|secret|token|privateKey/iu;
export const DEFAULT_WINDOW = { width: 1024, height: 768 };
export const DEFAULT_MACOS_MINIMUM = '13.0';
/**
* RFC 7386 JSON Merge Patch.
*
* A `null` in `patch` removes the key, which is how a per-OS file turns off a
* value the base set: `{"bundle": {"macos": null}}`. Arrays and scalars replace
* wholesale; only objects merge.
*/
export function mergePatch(target, patch) {
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
return patch;
}
const base =
typeof target === 'object' && target !== null && !Array.isArray(target)
? target
: {};
const result = { ...base };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete result[key];
} else {
result[key] = mergePatch(base[key], value);
}
}
return result;
}
/** `com.example.my-app`, from an app name. */
export function identifierFromName(name) {
return `com.example.${String(name)
.toLowerCase()
.replaceAll(/[^a-z0-9-]/gu, '-')}`;
}
/**
* The `desktop.json` path for an app.
*
* It lives in the app's existing `desktop/` folder, beside the Rust crate it
* configures.
*/
export function desktopConfigPath(projectRoot) {
return path.join(projectRoot, 'desktop', 'desktop.json');
}
function readJson(file, label) {
let text;
try {
text = fs.readFileSync(file, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') return undefined;
throw error;
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`${label} is not valid JSON: ${file}\n${error.message}`);
}
}
/**
* Loads the configuration for one OS.
*
* Reads `desktop/desktop.json`, merges `desktop/desktop.<os>.json` over it,
* fills the defaults, validates, and refuses a config carrying a secret. Pure
* apart from those reads.
*/
export function loadDesktopConfig(projectRoot, platform) {
const os = desktopOs(platform);
const basePath = path.join(projectRoot, 'desktop', 'desktop.json');
const osPath = path.join(projectRoot, 'desktop', `desktop.${os}.json`);
const appConfigPath = path.join(projectRoot, 'app.json');
const packageJsonPath = path.join(projectRoot, 'package.json');
const base = readJson(basePath, 'desktop/desktop.json');
if (base !== undefined) assertObject(base, 'desktop/desktop.json', '$');
const override = readJson(osPath, `desktop/desktop.${os}.json`);
if (override !== undefined) {
assertObject(override, `desktop/desktop.${os}.json`, '$');
}
// The key that used to hold these options. It is rejected rather than read:
// the option would be ignored, and the app would silently get a default.
const appConfig = readJson(appConfigPath, 'app.json') ?? {};
if (
appConfig.desktop !== undefined &&
appConfig.desktop !== null &&
typeof appConfig.desktop === 'object' &&
Object.keys(appConfig.desktop).length > 0
) {
throw new Error(
'move `app.json` desktop.* into `desktop/desktop.json` (see the README)',
);
}
const merged =
base === undefined && override === undefined
? {}
: mergePatch(base ?? {}, override ?? {});
// Secrets first: the schema rejects unknown keys, and "unknown key" is a
// misleading way to describe a password someone tried to commit.
assertNoSecrets(merged, '$');
const errors = [];
validateAgainst(merged, SCHEMA, '$', errors);
if (errors.length > 0) {
throw new Error(
`invalid desktop/desktop.json:\n${errors.map(error => ` ${error}`).join('\n')}`,
);
}
const packageJson = readJson(packageJsonPath, 'package.json') ?? {};
return withDefaults(merged, { appConfig, packageJson });
}
function assertObject(value, label, jsonPath) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`invalid ${label}: ${jsonPath} must be an object`);
}
}
/**
* Fills every field an app may omit.
*
* `window.title` defaults to `productName` rather than the app name, so a
* title bar and the app's own name are the same string unless asked otherwise.
*/
function withDefaults(config, { appConfig, packageJson }) {
const productName =
config.productName ??
appConfig.displayName ??
appConfig.name ??
packageJson.name ??
'App';
const identifier =
config.identifier ?? identifierFromName(appConfig.name ?? productName);
const version = config.version ?? packageJson.version ?? '1.0.0';
const window = { ...DEFAULT_WINDOW, ...(config.window ?? {}) };
if (window.title === undefined) window.title = productName;
if (window.resizable === undefined) window.resizable = true;
if (window.restoreState === undefined) window.restoreState = true;
const tray =
config.tray === undefined ? undefined : { accessory: true, ...config.tray };
const bundle = { ...(config.bundle ?? {}) };
if (bundle.targets === undefined) bundle.targets = 'all';
if (bundle.resources === undefined) bundle.resources = [];
bundle.macos = {
minimumSystemVersion: DEFAULT_MACOS_MINIMUM,
...(bundle.macos ?? {}),
};
if (bundle.macos.entitlements === undefined) bundle.macos.entitlements = null;
if (bundle.macos.infoPlist === undefined) bundle.macos.infoPlist = {};
bundle.windows = bundle.windows ?? {};
bundle.windows.nsis = {
installMode: 'currentUser',
...(bundle.windows.nsis ?? {}),
};
bundle.linux = bundle.linux ?? {};
bundle.linux.deb = { depends: [], ...(bundle.linux.deb ?? {}) };
bundle.linux.appimage = bundle.linux.appimage ?? {};
return {
productName,
identifier,
version,
...(config.icon === undefined ? {} : { icon: config.icon }),
window,
devMenu: config.devMenu ?? true,
...(tray === undefined ? {} : { tray }),
bundle,
};
}
/** The formats to build for one OS. */
export function targetsFor(config, platform) {
const os = desktopOs(platform);
const available = TARGETS_BY_OS[os] ?? [];
const requested = config.bundle?.targets ?? 'all';
const list = requested === 'all' ? available : requested;
const unknown = list.filter(target => !ALL_TARGETS.includes(target));
if (unknown.length > 0) {
throw new Error(
`unknown bundle target(s) ${unknown.join(', ')}; expected one of ${ALL_TARGETS.join(', ')} or 'all'`,
);
}
const unavailable = list.filter(target => !available.includes(target));
if (unavailable.length > 0) {
throw new Error(
`${unavailable.join(', ')} cannot be built on ${os}; available targets are ${available.join(', ')}`,
);
}
return list;
}
/**
* Walks the merged config and refuses any key that names signing material.
*
* The values are not quoted in the error: printing a leaked secret into a build
* log is the same mistake twice.
*/
function assertNoSecrets(value, jsonPath) {
if (Array.isArray(value)) {
value.forEach((item, index) =>
assertNoSecrets(item, `${jsonPath}[${index}]`),
);
return;
}
if (typeof value !== 'object' || value === null) return;
for (const [key, child] of Object.entries(value)) {
if (SECRET_KEY.test(key)) {
throw new Error(
`desktop/desktop.json must not contain signing material: ${jsonPath}.${key}. ` +
'Set it in the environment instead; see the README.',
);
}
assertNoSecrets(child, `${jsonPath}.${key}`);
}
}
/**
* Validates against `desktop.schema.json`.
*
* A small validator for exactly this schema, because the CLI ships with no
* dependencies and the plan asks before adding one. It reports the JSON path of
* the first problem at each location rather than only the first overall: a
* config with two typos should not need two runs to fix.
*
* The schema subset it implements is `type`, `enum`, `pattern`, `minLength`,
* `exclusiveMinimum`, `required`, `properties`, `additionalProperties`,
* `items`, `minItems`, `oneOf`.
*/
function typeMatches(value, type) {
switch (type) {
case 'object':
return (
typeof value === 'object' && value !== null && !Array.isArray(value)
);
case 'array':
return Array.isArray(value);
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && Number.isFinite(value);
case 'boolean':
return typeof value === 'boolean';
case 'null':
return value === null;
default:
return true;
}
}
function validateAgainst(value, schema, jsonPath, errors) {
const types =
schema.type === undefined
? undefined
: Array.isArray(schema.type)
? schema.type
: [schema.type];
if (types !== undefined && !types.some(type => typeMatches(value, type))) {
errors.push(`${jsonPath}: expected ${types.join(' or ')}`);
return;
}
if (schema.enum !== undefined && !schema.enum.includes(value)) {
errors.push(
`${jsonPath}: expected one of ${schema.enum.map(item => JSON.stringify(item)).join(', ')}`,
);
return;
}
if (schema.pattern !== undefined && typeof value === 'string') {
if (!new RegExp(schema.pattern, 'u').test(value)) {
errors.push(`${jsonPath}: does not match ${schema.pattern}`);
}
}
if (schema.minLength !== undefined && typeof value === 'string') {
if (value.length < schema.minLength) {
errors.push(`${jsonPath}: must not be empty`);
}
}
if (schema.exclusiveMinimum !== undefined && typeof value === 'number') {
if (value <= schema.exclusiveMinimum) {
errors.push(
`${jsonPath}: must be greater than ${schema.exclusiveMinimum}`,
);
}
}
if (schema.oneOf !== undefined) {
const matched = schema.oneOf.filter(branch => {
const branchErrors = [];
validateAgainst(value, branch, jsonPath, branchErrors);
return branchErrors.length === 0;
});
if (matched.length !== 1) {
errors.push(
`${jsonPath}: matches ${matched.length} of ${schema.oneOf.length} allowed shapes, expected exactly one`,
);
}
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) {
errors.push(`${jsonPath}: must have at least ${schema.minItems} item(s)`);
}
if (schema.items !== undefined) {
value.forEach((item, index) =>
validateAgainst(item, schema.items, `${jsonPath}[${index}]`, errors),
);
}
return;
}
if (typeof value === 'object' && value !== null) {
for (const required of schema.required ?? []) {
if (value[required] === undefined) {
errors.push(`${jsonPath}: missing required key ${required}`);
}
}
const properties = schema.properties ?? {};
for (const [key, child] of Object.entries(value)) {
const childPath = `${jsonPath}.${key}`;
if (properties[key] !== undefined) {
validateAgainst(child, properties[key], childPath, errors);
} else if (schema.additionalProperties === false) {
const known = Object.keys(properties).sort().join(', ');
errors.push(`${childPath}: unknown key; known keys are ${known}`);
}
}
}
}
/** The schema, read once. `desktop.schema.json` is shipped and documented. */
const SCHEMA = JSON.parse(
fs.readFileSync(new URL('../desktop.schema.json', import.meta.url), 'utf8'),
);
/** The schema itself, for `--print-schema` and for callers that ship it. */
export function desktopSchema() {
return SCHEMA;
}
@@ -0,0 +1,308 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import {
identifierFromName,
loadDesktopConfig,
mergePatch,
targetsFor,
} from './desktop-config.js';
const roots = [];
afterEach(() => {
while (roots.length > 0) {
fs.rmSync(roots.pop(), { recursive: true, force: true });
}
});
/**
* A minimal app: `app.json` and `package.json` only. `files` adds or replaces
* files by path relative to the project root.
*/
function createProject({ appJson, packageJson, files = {} } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-gpui-desktop-'));
roots.push(root);
fs.mkdirSync(path.join(root, 'desktop'), { recursive: true });
fs.writeFileSync(
path.join(root, 'app.json'),
`${JSON.stringify(
appJson ?? { name: 'MyApp', displayName: 'My App' },
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(root, 'package.json'),
`${JSON.stringify(
packageJson ?? { name: 'my-app', version: '2.3.4' },
null,
2,
)}\n`,
);
for (const [relative, contents] of Object.entries(files)) {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(
target,
typeof contents === 'string'
? contents
: JSON.stringify(contents, null, 2),
);
}
return root;
}
describe('defaults', () => {
test('an app with no desktop.json gets every default', () => {
const root = createProject();
const config = loadDesktopConfig(root, 'macos');
expect(config.productName).toBe('My App');
expect(config.identifier).toBe('com.example.myapp');
expect(config.version).toBe('2.3.4');
expect(config.window).toEqual({
width: 1024,
height: 768,
title: 'My App',
resizable: true,
restoreState: true,
});
expect(config.devMenu).toBe(true);
expect(config.tray).toBeUndefined();
expect(config.bundle.targets).toBe('all');
expect(config.bundle.resources).toEqual([]);
expect(config.bundle.macos.minimumSystemVersion).toBe('13.0');
expect(config.bundle.macos.entitlements).toBeNull();
expect(config.bundle.macos.infoPlist).toEqual({});
expect(config.bundle.windows.nsis.installMode).toBe('currentUser');
expect(config.bundle.linux.deb.depends).toEqual([]);
});
test('the window title defaults to productName, not the app name', () => {
const root = createProject({
files: { 'desktop/desktop.json': { productName: 'Shiny' } },
});
expect(loadDesktopConfig(root, 'macos').window.title).toBe('Shiny');
});
test('an explicit title wins over productName', () => {
const root = createProject({
files: {
'desktop/desktop.json': {
productName: 'Shiny',
window: { title: 'Other' },
},
},
});
expect(loadDesktopConfig(root, 'macos').window.title).toBe('Other');
});
test('an identifier is derived from the app name when absent', () => {
const root = createProject({ appJson: { name: 'My_App 2' } });
expect(loadDesktopConfig(root, 'macos').identifier).toBe(
'com.example.my-app-2',
);
expect(identifierFromName('A B')).toBe('com.example.a-b');
});
});
describe('per-OS overrides', () => {
test('desktop.<os>.json is merged over the base', () => {
const root = createProject({
files: {
'desktop/desktop.json': {
productName: 'Base',
window: { width: 800, height: 600 },
},
'desktop/desktop.macos.json': { window: { width: 1200 } },
},
});
const macos = loadDesktopConfig(root, 'macos');
expect(macos.productName).toBe('Base');
expect(macos.window.width).toBe(1200);
// Untouched keys keep the base value.
expect(macos.window.height).toBe(600);
// The override only applies to its own OS.
expect(loadDesktopConfig(root, 'linux').window.width).toBe(800);
});
test('a null removes a key, as RFC 7386 says', () => {
const root = createProject({
files: {
'desktop/desktop.json': {
tray: { component: 'MyTray', tooltip: 'Hi' },
},
'desktop/desktop.linux.json': { tray: null },
},
});
expect(loadDesktopConfig(root, 'macos').tray).toEqual({
component: 'MyTray',
tooltip: 'Hi',
accessory: true,
});
expect(loadDesktopConfig(root, 'linux').tray).toBeUndefined();
});
test('mergePatch replaces arrays rather than concatenating them', () => {
expect(
mergePatch(
{ bundle: { resources: ['a', 'b'] } },
{ bundle: { resources: ['c'] } },
),
).toEqual({ bundle: { resources: ['c'] } });
});
test('merging into a missing base fills from the override alone', () => {
expect(mergePatch(undefined, { a: { b: 1 } })).toEqual({ a: { b: 1 } });
});
});
describe('validation', () => {
test('an unknown key is refused with its JSON path', () => {
const root = createProject({
files: { 'desktop/desktop.json': { windo: { width: 1 } } },
});
expect(() => loadDesktopConfig(root, 'macos')).toThrow(
/\$\.windo: unknown key; known keys are/,
);
});
test('a wrong type names the offending path', () => {
const root = createProject({
files: { 'desktop/desktop.json': { window: { width: 'wide' } } },
});
expect(() => loadDesktopConfig(root, 'macos')).toThrow(
/\$\.window\.width: expected number/,
);
});
test('a nested unknown key is caught too', () => {
const root = createProject({
files: { 'desktop/desktop.json': { bundle: { macos: { nope: 1 } } } },
});
expect(() => loadDesktopConfig(root, 'macos')).toThrow(
/\$\.bundle\.macos\.nope: unknown key/,
);
});
test('two problems are both reported', () => {
const root = createProject({
files: {
'desktop/desktop.json': {
window: { width: 'wide' },
version: 7,
},
},
});
expect(() => loadDesktopConfig(root, 'macos')).toThrow(
/window\.width: expected number[\s\S]*\$\.version: expected string/,
);
});
test('a bad target is refused with the allowed list', () => {
const root = createProject({
files: { 'desktop/desktop.json': { bundle: { targets: ['msi'] } } },
});
expect(() => loadDesktopConfig(root, 'macos')).toThrow(
/\$\.bundle\.targets: (matches 0 of|unknown bundle target)/,
);
});
test('a target for another OS is refused by targetsFor, with what is available', () => {
// The schema allows any known format, because which ones an OS can build is
// a fact about the OS rather than about the file.
const root = createProject({
files: { 'desktop/desktop.json': { bundle: { targets: ['nsis'] } } },
});
const config = loadDesktopConfig(root, 'macos');
expect(() => targetsFor(config, 'macos')).toThrow(
/nsis cannot be built on macos; available targets are app, dmg/,
);
});
test('a file that is not JSON says so', () => {
const root = createProject({
files: { 'desktop/desktop.json': '{ nope' },
});
expect(() => loadDesktopConfig(root, 'macos')).toThrow(
/desktop\/desktop\.json is not valid JSON/,
);
});
});
describe('secrets', () => {
test.each([
['a password', { bundle: { macos: { password: 'hunter2' } } }],
['a top-level secret', { secret: 'abc' }],
['a token', { bundle: { windows: { token: 'abc' } } }],
['a private key', { bundle: { macos: { privateKey: '---' } } }],
['an oddly-cased name', { bundle: { macos: { PASSWORD: 'x' } } }],
])('%s is refused, and its value is not printed', (_name, config) => {
const root = createProject({ files: { 'desktop/desktop.json': config } });
let message = '';
try {
loadDesktopConfig(root, 'macos');
} catch (error) {
message = error.message;
}
expect(message).toMatch(/must not contain signing material/);
// Printing a leaked secret into a build log is the same mistake twice.
expect(message).not.toMatch(/hunter2|abc|---/u);
});
test('a key that merely resembles one is allowed', () => {
// `InfoPlist` contains "Plist", not a secret word; the check must not be so
// eager that it refuses the documented `infoPlist` setting.
const root = createProject({
files: {
'desktop/desktop.json': { bundle: { macos: { infoPlist: {} } } },
},
});
expect(() => loadDesktopConfig(root, 'macos')).not.toThrow();
});
});
describe('the app.json migration', () => {
test('a non-empty app.json desktop key is refused with the migration', () => {
const root = createProject({
appJson: { name: 'MyApp', desktop: { appId: 'com.example.app' } },
});
expect(() => loadDesktopConfig(root, 'macos')).toThrow(
'move `app.json` desktop.* into `desktop/desktop.json` (see the README)',
);
});
test('an empty app.json desktop key is tolerated', () => {
const root = createProject({
appJson: { name: 'MyApp', desktop: {} },
});
expect(() => loadDesktopConfig(root, 'macos')).not.toThrow();
});
test('no app.json desktop key at all is fine', () => {
const root = createProject();
expect(() => loadDesktopConfig(root, 'macos')).not.toThrow();
});
});
describe('targetsFor', () => {
const config = targets => ({ bundle: { targets } });
test("'all' expands to the current OS's alpha formats", () => {
expect(targetsFor(config('all'), 'macos')).toEqual(['app', 'dmg']);
expect(targetsFor(config('all'), 'windows')).toEqual(['nsis']);
expect(targetsFor(config('all'), 'linux')).toEqual(['deb', 'appimage']);
});
test('an explicit list is filtered to what the OS can build', () => {
expect(targetsFor(config(['app']), 'macos')).toEqual(['app']);
});
test('an unknown format names every allowed one', () => {
expect(() => targetsFor(config(['msi']), 'macos')).toThrow(
/unknown bundle target\(s\) msi; expected one of app, dmg, nsis, deb, appimage or 'all'/,
);
});
});
@@ -0,0 +1,718 @@
/**
* The libraries and files a packaged app has to ship.
*
* A packaged app is copied to a machine that has no build tree, so every shared
* library the executable links at run time has to travel with it: the engine,
* the prebuilt runtime and anything a module built as a shared library. The list
* is read from what autolink recorded and from the build output, never from a
* hard-coded table, so a module that adds a library is shipped without anyone
* updating this file.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
// The engine's `runtimeFiles` is a CommonJS module inside the engine package, so
// it is loaded with a require rooted at this file rather than imported.
const require = createRequire(import.meta.url);
/** Where an app's autolink manifest lives. */
export function autolinkManifestPath(projectRoot) {
return path.join(projectRoot, 'desktop', 'autolink', 'autolink.json');
}
/** The shared-library extension for an OS, or `undefined` for another OS. */
export function libraryExtension(os) {
switch (os) {
case 'macos':
return '.dylib';
case 'windows':
return '.dll';
case 'linux':
return '.so';
default:
return undefined;
}
}
/**
* Whether a file name is a shared library a packaged app must ship.
*
* Versioned Linux libraries (`libfoo.so.1`) count: the loader asks for the
* versioned name, and shipping only the `libfoo.so` symlink ships nothing.
*/
function isSharedLibrary(name, extension) {
if (extension === undefined) return false;
if (name.endsWith(extension)) return true;
return extension === '.so' && /\.so(\.\d+)+$/u.test(name);
}
/** The files in `directory` that are shared libraries, sorted. */
function sharedLibrariesIn(directory, extension) {
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter(entry => entry.isFile() && isSharedLibrary(entry.name, extension))
.map(entry => path.join(directory, entry.name))
.sort();
}
/**
* Every runtime library a packaged app must carry.
*
* `os` and `arch` are the ones being packaged — Node's `process.platform` and
* `process.arch` spellings — so this is testable without packaging anything.
*
* Returns absolute paths, de-duplicated and sorted. A missing autolink manifest
* is an empty list rather than an error: an app with no autolinked modules has
* nothing extra to ship.
*/
export function runtimeLibraries(
projectRoot,
{ os, arch, release = true } = {},
) {
const platform = os ?? process.platform;
const architecture = arch ?? process.arch;
const extension = libraryExtension(
platform === 'darwin'
? 'macos'
: platform === 'win32'
? 'windows'
: 'linux',
);
const libraries = new Set();
const manifest = readManifest(autolinkManifestPath(projectRoot));
if (manifest !== undefined) {
// The engine names its own files, because only it knows what its platform
// build produces (a dylib, a DLL, and on some builds extra archives).
for (const engine of manifest.jsEngines ?? []) {
if (typeof engine.runtimeFiles !== 'string' || !engine.runtimeFiles) {
continue;
}
let files;
try {
const resolve = require(engine.runtimeFiles);
files = resolve(platform, architecture) ?? [];
} catch {
continue;
}
for (const file of files) {
if (isSharedLibrary(path.basename(file), extension)) {
libraries.add(path.resolve(file));
}
}
}
}
// Libraries the build produced. The runtime's own dependencies land beside
// the crate that built them, so every candidate directory is scanned rather
// than a single assumed one.
for (const directory of buildLibraryDirectories(projectRoot, { release })) {
for (const file of sharedLibrariesIn(directory, extension)) {
libraries.add(file);
}
}
// Finally, and most reliably, ask the executable what it links. A library can
// live outside both the profile directory and a crate's `out` — the C++
// bridge's `libjsi` is built by CMake under its own tree — and only the
// binary's own dependencies say what it needs at run time.
for (const file of linkedSharedLibraries(projectRoot, {
release,
extension,
})) {
libraries.add(file);
}
// One file per name. Two paths can supply the same library — the prebuilt cache
// and the CMake tree both hold `hermesvm` — and the packaging configs map each
// entry to a destination by name, so a second entry would silently target the
// same file twice. The first path wins, which is the recorded build order.
const byName = new Map();
for (const library of libraries) {
const name = path.basename(library);
if (!byName.has(name)) byName.set(name, library);
}
return [...byName.values()].sort();
}
/**
* The shared libraries an executable links that are not system libraries.
*
* `otool -L` on macOS, `readelf -d` on Linux. A system library resolves from the
* OS and must not be bundled; anything resolved through a `@rpath` entry, or
* living under the build, is ours to ship.
*/
export function linkedSharedLibraries(projectRoot, { release, extension }) {
const binary = path.join(
targetProfileDir(
path.join(projectRoot, 'desktop'),
release ? 'release' : 'debug',
),
executableName(projectRoot),
);
if (!fs.existsSync(binary)) return [];
let output;
try {
if (process.platform === 'darwin') {
output = execFileSync('otool', ['-L', binary], { encoding: 'utf8' });
} else if (process.platform === 'linux') {
output = execFileSync('readelf', ['-d', binary], { encoding: 'utf8' });
} else {
return [];
}
} catch {
return [];
}
const found = new Set();
for (const reference of parseSharedLibraryReferences(
output,
process.platform,
)) {
const resolved = resolveLinkedLibrary(reference, {
binary,
projectRoot,
});
if (resolved === undefined) continue;
if (isSharedLibrary(path.basename(resolved), extension))
found.add(resolved);
}
return [...found];
}
/**
* The library references in `otool -L` or `readelf -d` output.
*
* Pure, and separate from running the tool, because the two formats differ in
* the way that matters most: a Mach-O dependency names a path (`@rpath/foo`,
* `/abs/foo`), while an ELF `NEEDED` entry is a bare soname (`libjsi.so`) that
* says nothing about where the file came from. That difference is invisible to
* anyone developing on macOS, so it is tested against recorded output.
*/
export function parseSharedLibraryReferences(output, platform) {
const references = [];
for (const line of output.split('\n')) {
// `otool -L`: an indented path, then the compatibility version in parens.
// The first line is the binary's own path and must be ignored.
const machO = platform === 'darwin' ? line.match(/^\t(\S+)\s+\(/u) : null;
// `readelf -d`: `0x… (NEEDED) Shared library: [libjsi.so]`.
const elf = line.match(/NEEDED\)\s+Shared library: \[(\S+)\]/u);
const match = machO ?? elf;
if (match === null) continue;
if (!references.includes(match[1])) references.push(match[1]);
}
return references;
}
/**
* `resolveLinkedLibrary`, exposed for tests.
*
* The bare-soname case only happens on Linux, where these tests cannot run, so
* the lookup has to be exercised from macOS against a synthesised build tree.
*/
export function resolveLinkedLibraryForTest(reference, projectRoot) {
return resolveLinkedLibrary(reference, {
binary: path.join(projectRoot, 'desktop', 'target', 'release', 'my-app'),
projectRoot,
});
}
/**
* Where a `@rpath`/`$ORIGIN` reference actually points, or `None` for a system
* library.
*
* The executable's own rpaths are what the loader uses, so they are what this
* walks. A reference into `/usr/lib` is the OS's to provide.
*/
function resolveLinkedLibrary(reference, { binary, projectRoot }) {
if (reference.startsWith('@rpath/')) {
const name = reference.slice('@rpath/'.length);
for (const rpath of rpathsOf(binary)) {
const candidate = path.join(rpath, name);
if (fs.existsSync(candidate)) return candidate;
}
// A packaging build carries only relative rpaths, so the absolute library
// directories are not in the file to be read back out. The directories the
// build recorded are, and they name the config it actually produced.
for (const directory of releaseLibraryDirectories(projectRoot)) {
const candidate = path.join(directory, name);
if (fs.existsSync(candidate)) return candidate;
}
return undefined;
}
if (reference.startsWith('$ORIGIN')) {
const candidate = path.join(
path.dirname(binary),
reference.slice('$ORIGIN/'.length),
);
return fs.existsSync(candidate) ? candidate : undefined;
}
if (!path.isAbsolute(reference)) {
// ELF `NEEDED` entries are bare sonames (`libjsi.so`), not paths, so a Linux
// binary says nothing about where its libraries came from. Look for the name
// in the directories the build produced; a system library will not be there
// and is correctly skipped.
for (const directory of [
// What the build recorded first — it names the prebuilt cache and the
// engine's own directory — then the release config's tree, then Cargo's
// release output.
...releaseLibraryDirectories(projectRoot),
...buildLibraryDirectories(projectRoot, { release: true }),
]) {
const candidate = path.join(directory, reference);
if (fs.existsSync(candidate)) return candidate;
}
return undefined;
}
// A library under the repository belongs to the build; one under /usr or
// /System belongs to the OS.
if (reference.startsWith('/usr/') || reference.startsWith('/System/')) {
return undefined;
}
return reference.startsWith(projectRoot) ? reference : undefined;
}
/** The rpaths a Mach-O binary carries, absolute entries only. */
function rpathsOf(binary) {
let output;
try {
output = execFileSync('otool', ['-l', binary], { encoding: 'utf8' });
} catch {
return [];
}
const out = [];
for (const line of output.split('\n')) {
const match = line.match(/^\s+path (\S+) \(offset/u);
if (match !== null && path.isAbsolute(match[1])) out.push(match[1]);
}
return out;
}
/**
* The library directories the build itself recorded.
*
* `rn-gpui-sys` generates `rpath_dirs-<Config>.txt` while configuring CMake, and
* that list is what ends up in the executable's rpath. It is the only accurate
* answer: it names the *prebuilt* runtime's cache directory when the app used a
* prebuilt, and the Hermes binary directory, neither of which is under the build
* tree. Reading it is why a library cannot be missed by guessing at layouts.
*/
/// Where the CMake tree for this build lives.
///
/// It sits beside the Cargo profile directory, so it moves with
/// `CARGO_TARGET_DIR`, which CI sets.
function cmakeRoot(projectRoot) {
return path.join(
path.dirname(
targetProfileDir(path.join(projectRoot, 'desktop'), 'release'),
),
'rn-gpui-cmake',
);
}
/**
* The release config directories under one triple.
*
* Only a release config may be searched: a `Debug` build's libraries are a
* different build, possibly against the other runtime, and a package that ships
* one is broken in a way no clean CI run can see — a fresh runner has only the
* single config it just built, while a developer's tree accumulates them all.
*/
function releaseConfigRoots(root) {
const found = [];
let triples;
try {
triples = fs.readdirSync(root, { withFileTypes: true });
} catch {
return found;
}
for (const triple of triples) {
if (!triple.isDirectory()) continue;
const tripleDir = path.join(root, triple.name);
let configs;
try {
configs = fs.readdirSync(tripleDir, { withFileTypes: true });
} catch {
continue;
}
for (const config of configs) {
if (config.isDirectory() && RELEASE_CONFIG.test(config.name)) {
found.push(path.join(tripleDir, config.name));
}
}
}
// A prebuilt config first: it records the cache the package is actually
// linked against, so its answer outranks a source config's.
const rank = directory => (directory.endsWith('-prebuilt') ? 0 : 1);
return found.sort((a, b) => rank(a) - rank(b));
}
/**
* Every directory a release build may have put a shared library in.
*
* The recorded rpaths come first because they are the build's own answer, then
* the current release config's tree. Anything outside a release config is
* excluded on purpose — see `releaseConfigRoots`.
*/
export function releaseLibraryDirectories(projectRoot) {
return [
...recordedLibraryDirectories(projectRoot),
...cmakeLibraryDirectories(projectRoot),
];
}
export function recordedLibraryDirectories(projectRoot) {
const root = cmakeRoot(projectRoot);
const directories = [];
const candidates = releaseConfigRoots(root).flatMap(config =>
walkDirectories(config),
);
for (const file of candidates) {
let entries;
try {
entries = fs.readdirSync(file, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !/^rpath_dirs-.*\.txt$/u.test(entry.name))
continue;
let text;
try {
text = fs.readFileSync(path.join(file, entry.name), 'utf8');
} catch {
continue;
}
for (const line of text.split('\n')) {
const directory = line.trim();
if (directory && !directories.includes(directory)) {
directories.push(directory);
}
}
}
}
return directories;
}
/**
* Directories under the C++ build tree that hold its shared libraries.
*
* `libjsi` is built by CMake, not Cargo, so it is in neither the profile
* directory nor a crate's `out`: it sits under
* `<crate>/build/target/rn-gpui-cmake/<triple>/<config>/build/...`.
*/
function cmakeLibraryDirectories(projectRoot) {
const found = [];
for (const configRoot of releaseConfigRoots(cmakeRoot(projectRoot))) {
// The libraries are at different depths per dependency, so walk.
for (const entry of walkDirectories(configRoot)) found.push(entry);
}
return found;
}
/// A CMake config directory that produces a release binary.
///
/// `Release-prebuilt` is what a prebuilt build configures; `RelWithDebInfo` is
/// what an optimised build gets when debug info is kept. `Debug*` is excluded.
const RELEASE_CONFIG = /^(?:Release|RelWithDebInfo)(?:-prebuilt)?$/u;
/** Every directory at or below `root`, breadth-first, bounded in depth. */
function walkDirectories(root, depth = 0) {
if (depth > 5) return [];
const out = [root];
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (entry.isDirectory())
out.push(...walkDirectories(path.join(root, entry.name), depth + 1));
}
return out;
}
/** The executable's file name, from the desktop crate's Cargo.toml. */
function executableName(projectRoot) {
try {
const manifest = fs.readFileSync(
path.join(projectRoot, 'desktop', 'Cargo.toml'),
'utf8',
);
return manifest.match(/^name\s*=\s*"([^"]+)"/mu)?.[1] ?? '';
} catch {
return '';
}
}
function readManifest(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return undefined;
}
}
/**
* Directories the build may have put shared libraries in.
*
* Both the crate's own target directory and the one Cargo was pointed at
* (`CARGO_TARGET_DIR`), because the CLI sets the latter for reproducible paths.
*/
export function buildLibraryDirectories(projectRoot, { release = true } = {}) {
const profile = release ? 'release' : 'debug';
const crateDir = path.join(projectRoot, 'desktop');
// `targetProfileDir` already applies `CARGO_TARGET_DIR` and the crate's own
// `target-dir`, so the other roots are only fallbacks for a library that
// landed somewhere less predictable.
const roots = [
path.dirname(targetProfileDir(crateDir, profile)),
path.join(crateDir, 'target'),
path.join(projectRoot, 'target'),
].filter(Boolean);
const directories = new Set();
for (const root of roots) {
directories.add(path.join(root, profile));
// The prebuilt runtime's shared library is placed under the sys crate's
// build output, which is keyed by a hash that is not predictable.
const buildRoot = path.join(root, profile, 'build');
let entries;
try {
entries = fs.readdirSync(buildRoot, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
directories.add(path.join(buildRoot, entry.name, 'out'));
}
}
return [...directories];
}
/// The cargo-packager version this CLI drives and installs.
///
/// Pinned so a config written for one version is not silently handed to another:
/// the translator emits this version's schema, and an installed copy that differs
/// is replaced.
export const CARGO_PACKAGER_VERSION = '0.11.8';
/**
* Signing variables cargo-packager reads from the environment itself.
*
* Nothing here is ever written into a config file: the packager skips these
* fields when parsing JSON precisely so a secret cannot be committed, and this
* CLI passes its own environment through unchanged so setting one in the shell
* or in CI is all a user does. They are listed so a test can assert their values
* never reach the generated config.
*/
export const SIGNING_ENVIRONMENT = [
'APPLE_CERTIFICATE',
'APPLE_CERTIFICATE_PASSWORD',
'APPLE_SIGNING_IDENTITY',
'APPLE_PROVIDER_SHORT_NAME',
'APPLE_TEAM_ID',
'APPLE_ID',
'APPLE_PASSWORD',
'APPLE_API_KEY',
'APPLE_API_ISSUER',
'APPLE_API_KEY_PATH',
'WINDOWS_CERTIFICATE_THUMBPRINT',
'WINDOWS_TIMESTAMP_SERVER_URL',
'WINDOWS_TSP',
];
/** cargo-packager's name for each of this phase's formats. */
const PACKAGER_FORMAT = {
app: 'app',
dmg: 'dmg',
nsis: 'nsis',
deb: 'deb',
appimage: 'appimage',
};
/**
* Where Cargo put the executable for a profile.
*
* The desktop crate's `.cargo/config.toml` redirects `target-dir` to
* `build/target`, so `target/release` does not exist. Reading the setting is the
* only way to be right for both layouts.
*/
export function targetProfileDir(crateDir, profile) {
// `CARGO_TARGET_DIR` overrides everything, and CI sets it so the build cache
// survives a deleted app folder.
if (process.env.CARGO_TARGET_DIR) {
return path.join(path.resolve(process.env.CARGO_TARGET_DIR), profile);
}
const compiledIn = path.join(crateDir, 'target', profile);
let config;
try {
config = fs.readFileSync(
path.join(crateDir, '.cargo', 'config.toml'),
'utf8',
);
} catch {
return compiledIn;
}
const match = config.match(/^\s*target-dir\s*=\s*"([^"]+)"/mu);
if (!match) return compiledIn;
// Cargo resolves a relative `target-dir` from the directory holding `.cargo/`,
// which is the crate, and it may climb out of the crate (`../../../target`).
return path.resolve(crateDir, match[1], profile);
}
/**
* Translates `desktop.json` into cargo-packager's config.
*
* cargo-packager is an implementation detail: users never write its JSON, and
* replacing it would only change this function. Pure, so the whole translation
* is unit-testable without packaging anything.
*
* `build` carries what the caller observed — the crate directory, the
* executable's name, the directories holding the bundle and assets, the icon
* files and the libraries to ship — because none of that is in the config file.
*/
export function toPackagerConfig(config, build) {
const {
crateDir,
binaryName,
bundleDir,
assetsDir,
icons = [],
libraries = [],
formats,
outDir,
os,
rpathDir,
} = build;
// cargo-packager resolves paths relative to the crate directory, and its
// schema uses forward slashes on every platform.
const relative = absolute =>
path.relative(crateDir, absolute).split(path.sep).join('/');
// The bundle and the assets travel as resources, so the release build's
// `resource_dir` finds them beside the executable. The bundle file and the
// assets directory are named separately rather than shipping the whole build
// directory, which holds the output of this very run.
const resources = [];
if (bundleDir !== undefined) {
// One file, one name. `package-desktop` stages it; there is no candidate list
// to get wrong, which is how a plain-JS bundle was shipped while the bytecode
// sat unused beside it.
const bundle = path.join(bundleDir, 'index.bundle');
if (!fs.existsSync(bundle)) {
throw new Error(`the staged bundle is missing: ${bundle}`);
}
resources.push(relative(bundle));
}
if (assetsDir !== undefined && fs.existsSync(assetsDir)) {
resources.push(relative(assetsDir));
}
// On Windows the runtime DLLs are already staged beside the executable by the
// app's build.rs, and the loader looks there — but NSIS installs only the
// binary and the resources, so "beside the executable" has to be expressed as
// resources or the DLLs are left behind. Elsewhere they are handled per format
// below (`Frameworks`, `lib/<app>`).
if (os === 'windows') {
for (const library of libraries) resources.push(relative(library));
}
for (const extra of config.bundle.resources ?? []) {
// `bundle.resources` is written relative to `desktop/`, which is the crate.
resources.push(relative(path.resolve(crateDir, extra)));
}
const config_ = {
$schema:
'https://raw.githubusercontent.com/crabnebula-dev/cargo-packager/dev/crates/packager/schema.json',
name: binaryName,
productName: config.productName,
version: config.version,
identifier: config.identifier,
// `binariesDir` is where the executables are; `outDir` is where the
// packages go. Without the first, cargo-packager looks for the binary
// inside the output directory.
binariesDir: targetProfileDir(crateDir, 'release'),
binaries: [{ path: binaryName, main: true }],
outDir: outDir ?? path.join(crateDir, 'build', 'dist'),
resources,
};
if (formats !== undefined) {
config_.formats = formats.map(format => PACKAGER_FORMAT[format]);
}
if (icons.length > 0) config_.icons = icons;
if (config.bundle.copyright !== undefined) {
config_.copyright = config.bundle.copyright;
}
if (config.bundle.category !== undefined) {
config_.category = config.bundle.category;
}
const macos = {
minimumSystemVersion: config.bundle.macos.minimumSystemVersion,
};
if (config.bundle.macos.entitlements !== null) {
macos.entitlements = relative(
path.resolve(process.cwd(), config.bundle.macos.entitlements),
);
}
if (libraries.length > 0) {
// These are *source* paths, and absolute: cargo-packager copies each one
// into Contents/Frameworks, which is where the release build's
// `@executable_path/../Frameworks` rpath looks. They are absolute because a
// library can live outside the crate — the engine's come from its own
// package — and cargo-packager opens the string as given rather than
// resolving it against the config file.
macos.frameworks = libraries;
}
config_.macos = macos;
if (config.bundle.windows.nsis !== undefined) {
config_.nsis = { installMode: config.bundle.windows.nsis.installMode };
}
// Linux keeps its libraries under `lib/<app>`, which is where the release
// build's `$ORIGIN/../lib/<app>` rpath looks. Both Linux formats map a
// source path to a destination inside the package, so the library goes to
// that directory rather than wherever resources land by default.
if (libraries.length > 0) {
// The two Linux formats need different directories, because they find
// libraries differently:
// - the deb installs to `usr/lib/<app>`, which is where the release build's
// `$ORIGIN/../lib/<app>` rpath looks;
// - the AppImage's `AppRun` puts `$APPDIR/usr/lib` on the library path, and
// `linuxdeploy` resolves the executable's dependencies from there before
// the bundle is assembled. A library one directory deeper is reported as
// `Could not find dependency`, which is how this was found.
const debFiles = {};
const appimageFiles = {};
for (const library of libraries) {
const name = path.basename(library);
// `rpathDir` is the directory the release build's rpath actually names,
// which is the app name from `app.json` — not the crate name and not
// `productName`. Using anything else installs the libraries one directory
// away from where the binary looks, and it fails to start.
debFiles[library] = `/usr/lib/${rpathDir}/${name}`;
appimageFiles[library] = `/usr/lib/${name}`;
}
config_.deb = { ...(config_.deb ?? {}), files: debFiles };
config_.appimage = { files: appimageFiles };
}
if (config.bundle.linux.deb.depends.length > 0) {
config_.deb = {
...(config_.deb ?? {}),
depends: config.bundle.linux.deb.depends,
};
}
return config_;
}
@@ -0,0 +1,687 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import {
buildLibraryDirectories,
CARGO_PACKAGER_VERSION,
SIGNING_ENVIRONMENT,
libraryExtension,
parseSharedLibraryReferences,
recordedLibraryDirectories,
releaseLibraryDirectories,
resolveLinkedLibraryForTest,
runtimeLibraries,
targetProfileDir,
toPackagerConfig,
} from './package-desktop.js';
const roots = [];
afterEach(() => {
while (roots.length > 0) {
fs.rmSync(roots.pop(), { recursive: true, force: true });
}
});
/** A project with an autolink manifest and whatever files are asked for. */
function createProject({ engines = [], files = {} } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-gpui-package-'));
roots.push(root);
write(root, 'package.json', { name: 'my-app' });
if (engines.length > 0) {
write(root, 'desktop/autolink/autolink.json', { jsEngines: engines });
}
for (const [relative, contents] of Object.entries(files)) {
write(root, relative, contents);
}
return root;
}
function write(root, relative, contents) {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(
target,
typeof contents === 'string'
? contents
: `${JSON.stringify(contents, null, 2)}\n`,
);
}
describe('libraryExtension', () => {
test('each OS has its own shared-library extension', () => {
expect(libraryExtension('macos')).toBe('.dylib');
expect(libraryExtension('windows')).toBe('.dll');
expect(libraryExtension('linux')).toBe('.so');
});
});
describe('runtimeLibraries', () => {
test('an engine that names its own files is read from its module', () => {
const root = createProject();
const engineDir = path.join(root, 'engine');
fs.mkdirSync(engineDir, { recursive: true });
const library = path.join(engineDir, 'libhermesvm.dylib');
fs.writeFileSync(library, 'x');
fs.writeFileSync(
path.join(engineDir, 'runtime-files.js'),
`const path = require('node:path');
module.exports = (platform) =>
platform === 'darwin' ? [path.join(__dirname, 'libhermesvm.dylib')] : [];
`,
);
write(root, 'desktop/autolink/autolink.json', {
jsEngines: [
{
name: 'hermes',
runtimeFiles: path.join(engineDir, 'runtime-files.js'),
},
],
});
// The engine resolves its own directory, which on macOS reaches the same
// file through the `/var` -> `/private/var` symlink.
const libs = runtimeLibraries(root, { os: 'darwin', arch: 'arm64' });
expect(libs.map(file => fs.realpathSync(file))).toContain(
fs.realpathSync(library),
);
});
test('a library the engine does not name for this platform is skipped', () => {
const root = createProject();
const engineDir = path.join(root, 'engine');
fs.mkdirSync(engineDir, { recursive: true });
fs.writeFileSync(path.join(engineDir, 'libhermesvm.dylib'), 'x');
fs.writeFileSync(
path.join(engineDir, 'runtime-files.js'),
`const path = require('node:path');
module.exports = (platform) =>
platform === 'darwin' ? [path.join(__dirname, 'libhermesvm.dylib')] : [];
`,
);
write(root, 'desktop/autolink/autolink.json', {
jsEngines: [
{
name: 'hermes',
runtimeFiles: path.join(engineDir, 'runtime-files.js'),
},
],
});
expect(runtimeLibraries(root, { os: 'win32', arch: 'x64' })).toEqual([]);
});
test('shared libraries the build produced are found beside the target', () => {
const root = createProject();
write(root, 'desktop/target/release/libruntime.dylib', 'x');
write(root, 'desktop/target/release/notes.txt', 'not a library');
const libs = runtimeLibraries(root, { os: 'darwin', arch: 'arm64' });
expect(libs.map(file => path.basename(file))).toEqual(['libruntime.dylib']);
});
test('the sys crate build output is scanned, keyed by its hash', () => {
const root = createProject();
write(
root,
'desktop/target/release/build/rn-gpui-sys-abc123/out/libjsi.dylib',
'x',
);
const libs = runtimeLibraries(root, { os: 'darwin', arch: 'arm64' });
expect(libs.map(file => path.basename(file))).toEqual(['libjsi.dylib']);
});
test('a versioned Linux library counts as a library', () => {
const root = createProject();
write(root, 'desktop/target/release/libfoo.so.1.2.3', 'x');
write(root, 'desktop/target/release/libfoo.so', 'x');
const libs = runtimeLibraries(root, { os: 'linux', arch: 'x64' });
expect(libs.map(file => path.basename(file)).sort()).toEqual([
'libfoo.so',
'libfoo.so.1.2.3',
]);
});
test('a library for another OS is not shipped', () => {
const root = createProject();
write(root, 'desktop/target/release/libfoo.dylib', 'x');
expect(runtimeLibraries(root, { os: 'win32', arch: 'x64' })).toEqual([]);
});
test('a debug build reads the debug profile', () => {
const root = createProject();
write(root, 'desktop/target/debug/libruntime.dylib', 'x');
write(root, 'desktop/target/release/librelease.dylib', 'x');
expect(
runtimeLibraries(root, {
os: 'darwin',
arch: 'arm64',
release: false,
}).map(file => path.basename(file)),
).toEqual(['libruntime.dylib']);
});
test('a project with no autolink manifest ships no engine files', () => {
const root = createProject();
expect(runtimeLibraries(root, { os: 'darwin', arch: 'arm64' })).toEqual([]);
});
test('the same library named twice is shipped once', () => {
const root = createProject();
write(root, 'desktop/target/release/libfoo.dylib', 'x');
const libs = runtimeLibraries(root, { os: 'darwin', arch: 'arm64' });
expect(new Set(libs).size).toBe(libs.length);
});
});
describe('buildLibraryDirectories', () => {
test('the release and debug profiles are distinct', () => {
const root = createProject();
expect(buildLibraryDirectories(root, { release: true })[0]).toContain(
path.join('desktop', 'target', 'release'),
);
expect(buildLibraryDirectories(root, { release: false })[0]).toContain(
path.join('desktop', 'target', 'debug'),
);
});
});
// The translator is the whole reason cargo-packager is an implementation
// detail: these hold what it emits for one config, so replacing the packager
// only changes one function and one set of expectations.
test('every package ships an icon', () => {
// cargo-packager's AppImage step refuses to build without a square icon, so
// the CLI always supplies one: the app's, or its own placeholder. An app that
// never set `icon` still packages every format.
const withIcon = icons =>
toPackagerConfig(
{
productName: 'A',
identifier: 'com.a.b',
version: '1.0.0',
bundle: {
resources: [],
macos: { minimumSystemVersion: '13.0', entitlements: null },
windows: {},
linux: { deb: { depends: [] }, appimage: {} },
},
},
{ crateDir: '/p/desktop', binaryName: 'a', icons },
);
expect(withIcon(['build/icons/icon.png']).icons).toEqual([
'build/icons/icon.png',
]);
expect(withIcon(['assets/icon.png']).icons).toEqual(['assets/icon.png']);
});
test('the packager version is pinned to one release', () => {
// The translator emits this version's schema, and `ensureCargoPackager`
// installs it, so the two cannot drift apart.
expect(CARGO_PACKAGER_VERSION).toMatch(/^\d+\.\d+\.\d+$/u);
});
describe('toPackagerConfig', () => {
const desktopConfig = overrides => {
const { bundle: bundleOverrides, ...rest } = overrides ?? {};
return {
productName: 'My App',
identifier: 'com.example.myapp',
version: '1.2.3',
window: { width: 1024, height: 768 },
devMenu: true,
...rest,
bundle: {
targets: 'all',
resources: [],
macos: {
minimumSystemVersion: '13.0',
entitlements: null,
infoPlist: {},
},
windows: { nsis: { installMode: 'currentUser' } },
linux: { deb: { depends: [] }, appimage: {} },
...bundleOverrides,
},
};
};
const build = overrides => ({
crateDir: '/project/desktop',
binaryName: 'my-app',
formats: ['app', 'dmg'],
...overrides,
});
test('a minimal app gives the fields cargo-packager needs', () => {
const config = toPackagerConfig(desktopConfig(), build());
expect(config.productName).toBe('My App');
expect(config.identifier).toBe('com.example.myapp');
expect(config.version).toBe('1.2.3');
expect(config.binariesDir).toBe('/project/desktop/target/release');
expect(config.binaries).toEqual([{ path: 'my-app', main: true }]);
expect(config.formats).toEqual(['app', 'dmg']);
expect(config.outDir).toBe('/project/desktop/build/dist');
});
test('the bundle file and the assets directory ship as resources', () => {
// A directory containing this run's output cannot be shipped, so the two
// are named. The asset directory has to exist to be listed.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-gpui-res-'));
roots.push(root);
const crateDir = path.join(root, 'desktop');
fs.mkdirSync(path.join(crateDir, 'build', 'assets'), { recursive: true });
fs.writeFileSync(path.join(crateDir, 'build', 'index.bundle'), 'x');
const config = toPackagerConfig(
desktopConfig(),
build({
crateDir,
bundleDir: path.join(crateDir, 'build'),
assetsDir: path.join(crateDir, 'build', 'assets'),
}),
);
expect(config.resources).toEqual(['build/index.bundle', 'build/assets']);
});
test('a missing staged bundle is an error, not a silent omission', () => {
// A package without its bundle starts and then dies on a machine with no
// build tree, so the failure belongs here rather than at first launch.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-gpui-res-'));
roots.push(root);
const crateDir = path.join(root, 'desktop');
fs.mkdirSync(path.join(crateDir, 'build'), { recursive: true });
expect(() =>
toPackagerConfig(
desktopConfig(),
build({ crateDir, bundleDir: path.join(crateDir, 'build') }),
),
).toThrow(/staged bundle is missing/u);
});
test('extra resources are relative to the crate', () => {
const config = toPackagerConfig(
desktopConfig({ bundle: { resources: ['extra/data.json'] } }),
build(),
);
expect(config.resources).toEqual(['extra/data.json']);
});
test('runtime libraries become macOS frameworks, by source path', () => {
// cargo-packager copies each entry into Contents/Frameworks itself, so the
// entry is where the library is, not where it lands.
const config = toPackagerConfig(
desktopConfig(),
build({ libraries: ['/project/desktop/build/libjsi.dylib'] }),
);
expect(config.macos.frameworks).toEqual([
'/project/desktop/build/libjsi.dylib',
]);
});
test('runtime libraries get the directory each Linux format searches', () => {
// The deb installs to `usr/lib/<app>`, where the binary's
// `$ORIGIN/../lib/<app>` rpath looks. The AppImage needs flat `usr/lib`,
// which is what AppRun puts on the library path and where linuxdeploy looks
// when resolving the executable's dependencies.
const config = toPackagerConfig(
desktopConfig(),
build({ rpathDir: 'My App', libraries: ['/build/libjsi.so'] }),
);
// `rpathDir` is the app name the rpath names, not the crate name: the
// libraries have to land where the binary actually looks.
expect(config.deb.files).toEqual({
'/build/libjsi.so': '/usr/lib/My App/libjsi.so',
});
expect(config.appimage.files).toEqual({
'/build/libjsi.so': '/usr/lib/libjsi.so',
});
});
test('Windows ships the DLLs as resources', () => {
// NSIS installs the binary and the resources, not the profile directory
// beside the executable, so the DLLs have to be resources or they are left
// behind and the app fails to start.
const config = toPackagerConfig(
desktopConfig(),
build({
os: 'windows',
crateDir: '/project/desktop',
libraries: ['/project/desktop/build/hermesvm.dll'],
}),
);
expect(config.resources).toContain('build/hermesvm.dll');
});
test('macOS and Linux do not ship libraries as resources', () => {
for (const platform of ['macos', 'linux']) {
const config = toPackagerConfig(
desktopConfig(),
build({
os: platform,
libraries: ['/project/desktop/build/libjsi.dylib'],
}),
);
expect(config.resources).not.toContain('build/libjsi.dylib');
}
});
test('no libraries means no empty framework or files block', () => {
const config = toPackagerConfig(desktopConfig(), build());
expect(config.macos.frameworks).toBeUndefined();
expect(config.deb).toBeUndefined();
expect(config.appimage).toBeUndefined();
});
test('the NSIS install mode is carried over', () => {
const config = toPackagerConfig(
desktopConfig({
bundle: { windows: { nsis: { installMode: 'perMachine' } } },
}),
build({ formats: ['nsis'] }),
);
expect(config.nsis).toEqual({ installMode: 'perMachine' });
});
test('Debian dependencies are carried over', () => {
const config = toPackagerConfig(
desktopConfig({
bundle: { linux: { deb: { depends: ['libgtk-3-0'] } } },
}),
build({ formats: ['deb'] }),
);
expect(config.deb.depends).toEqual(['libgtk-3-0']);
});
test('a copyright and category reach the output when set', () => {
const config = toPackagerConfig(
desktopConfig({
bundle: { copyright: '© 2026 Me', category: 'DeveloperTool' },
}),
build(),
);
expect(config.copyright).toBe('© 2026 Me');
expect(config.category).toBe('DeveloperTool');
});
// D6: signing material comes from the environment, so no value of it may
// appear in the generated config, whatever the caller's environment holds.
test('no signing material appears in the output', () => {
const saved = new Map();
for (const name of SIGNING_ENVIRONMENT) {
saved.set(name, process.env[name]);
process.env[name] = `SECRET-${name}`;
}
try {
const text = JSON.stringify(
toPackagerConfig(
desktopConfig(),
build({ libraries: ['/b/lib.dylib'] }),
),
);
for (const name of SIGNING_ENVIRONMENT) {
expect(text).not.toContain(`SECRET-${name}`);
}
// Nor may the variable names themselves appear: a config naming a
// credential is a config someone will fill in.
for (const name of SIGNING_ENVIRONMENT) {
expect(text).not.toContain(name);
}
} finally {
for (const [name, value] of saved) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
});
test('paths use forward slashes on every platform', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-gpui-slash-'));
roots.push(root);
const crateDir = path.join(root, 'desktop');
fs.mkdirSync(path.join(crateDir, 'build'), { recursive: true });
fs.writeFileSync(path.join(crateDir, 'build', 'index.bundle'), 'x');
const config = toPackagerConfig(
desktopConfig(),
build({ crateDir, bundleDir: path.join(crateDir, 'build') }),
);
for (const entry of [
...config.resources,
...config.binaries.map(binary => binary.path),
]) {
expect(entry).not.toContain('\\');
}
});
});
describe('targetProfileDir', () => {
test('CARGO_TARGET_DIR wins over everything', () => {
// CI sets it so the Cargo cache survives a deleted app directory; a config
// that ignored it looked for the binary in a tree that does not exist.
const root = createProject();
const saved = process.env.CARGO_TARGET_DIR;
process.env.CARGO_TARGET_DIR = '/ci/target';
try {
expect(targetProfileDir(path.join(root, 'desktop'), 'release')).toBe(
path.join('/ci/target', 'release'),
);
} finally {
if (saved === undefined) delete process.env.CARGO_TARGET_DIR;
else process.env.CARGO_TARGET_DIR = saved;
}
});
test('a crate with no cargo config uses target/<profile>', () => {
const root = createProject();
expect(targetProfileDir(path.join(root, 'desktop'), 'release')).toBe(
path.join(root, 'desktop', 'target', 'release'),
);
});
test("the crate's target-dir setting wins", () => {
// The scaffold redirects `target-dir` to `build/target` so build output does
// not sit beside the sources; `target/release` then does not exist at all.
const root = createProject({
files: {
'desktop/.cargo/config.toml': '[build]\ntarget-dir = "build/target"\n',
},
});
expect(targetProfileDir(path.join(root, 'desktop'), 'release')).toBe(
path.join(root, 'desktop', 'build', 'target', 'release'),
);
});
test("the crate's target-dir may climb to a shared root target", () => {
// The tester desktop shares the repository's one `target/` by climbing out
// of its crate; resolving that from the crate is what finds the binary.
const root = createProject({
files: {
'desktop/.cargo/config.toml':
'[build]\ntarget-dir = "../../../target"\n',
},
});
expect(targetProfileDir(path.join(root, 'desktop'), 'release')).toBe(
path.resolve(root, 'desktop', '../../../target', 'release'),
);
});
});
// The Linux failure this covers was invisible from macOS for a whole round: an
// ELF `NEEDED` entry is a bare soname, so it must be resolved by searching the
// build, while a Mach-O dependency names its own path.
describe('parseSharedLibraryReferences', () => {
const readelf = `
0x0000000000000001 (NEEDED) Shared library: [libjsi.so]
0x0000000000000001 (NEEDED) Shared library: [libhermesvm.so]
0x0000000000000001 (NEEDED) Shared library: [libgtk-3.so.0]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/../lib/my-app]
`;
test('ELF NEEDED entries come back as bare sonames', () => {
expect(parseSharedLibraryReferences(readelf, 'linux')).toEqual([
'libjsi.so',
'libhermesvm.so',
'libgtk-3.so.0',
]);
});
test('a RUNPATH line is not mistaken for a library', () => {
expect(
parseSharedLibraryReferences(readelf, 'linux').some(reference =>
reference.includes('ORIGIN'),
),
).toBe(false);
});
test("otool's first line is the binary itself, not a dependency", () => {
const otool = [
'/build/app/Contents/MacOS/app:',
'\t@rpath/libjsi.dylib (compatibility version 0.0.0, current version 0.0.0)',
'\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.0.0)',
].join('\n');
expect(parseSharedLibraryReferences(otool, 'darwin')).toEqual([
'@rpath/libjsi.dylib',
'/usr/lib/libSystem.B.dylib',
]);
});
test('a repeated dependency is listed once', () => {
const repeated = `${readelf}\n 0x0 (NEEDED) Shared library: [libjsi.so]`;
expect(
parseSharedLibraryReferences(repeated, 'linux').filter(
reference => reference === 'libjsi.so',
),
).toHaveLength(1);
});
});
test('a bare soname is found in the CMake tree, and a system library is not', () => {
// The resolver cannot shell out to readelf on macOS, so the lookup it performs
// for a bare ELF name is tested directly. The layout is the scaffolded one:
// `.cargo/config.toml` redirects target-dir to `build/target`, and the C++
// runtime's libraries are under `rn-gpui-cmake/<triple>/<config>/...`.
const libjsi =
'desktop/build/target/rn-gpui-cmake/x86_64-unknown-linux-gnu/Release/build/ReactCommon/jsi/libjsi.so';
const root = createProject({
files: {
'desktop/.cargo/config.toml': '[build]\ntarget-dir = "build/target"\n',
'desktop/Cargo.toml': '[package]\nname = "my-app"\nversion = "0.1.0"\n',
[libjsi]: 'x',
},
});
expect(resolveLinkedLibraryForTest('libjsi.so', root)).toBe(
path.join(root, libjsi),
);
// A library the OS provides must not be shipped.
expect(resolveLinkedLibraryForTest('libgtk-3.so.0', root)).toBeUndefined();
});
describe('recordedLibraryDirectories', () => {
test('the directories CMake recorded are read back', () => {
// The build writes `rpath_dirs-<Config>.txt` while configuring, and that list
// becomes the executable's rpath. It is the only source that names the
// prebuilt runtime's cache, which is not under the build tree at all.
const root = createProject({
files: {
'desktop/.cargo/config.toml': '[build]\ntarget-dir = "build/target"\n',
'desktop/build/target/rn-gpui-cmake/x86_64-unknown-linux-gnu/Release/build/rpath_dirs-Release.txt':
'/opt/prebuilt/0.0.3/linux-x64/lib\n/opt/hermes/bin/linux-x64\n',
},
});
expect(recordedLibraryDirectories(root)).toEqual([
'/opt/prebuilt/0.0.3/linux-x64/lib',
'/opt/hermes/bin/linux-x64',
]);
});
test('a project with no build yet records nothing', () => {
expect(recordedLibraryDirectories(createProject())).toEqual([]);
});
});
test('a prebuilt-only library is found through the recorded directories', () => {
// The case that failed on Linux: the app uses the prebuilt runtime, so
// `libjsi.so` exists only in the prebuilt cache, outside both the project and
// the build tree. The recorded rpath list is what makes it findable.
const root = createProject({
files: {
'desktop/.cargo/config.toml': '[build]\ntarget-dir = "build/target"\n',
'desktop/Cargo.toml': '[package]\nname = "my-app"\nversion = "0.1.0"\n',
},
});
const cache = path.join(root, 'prebuilt-cache', 'linux-x64', 'lib');
fs.mkdirSync(cache, { recursive: true });
const library = path.join(cache, 'libjsi.so');
fs.writeFileSync(library, 'x');
write(
root,
'desktop/build/target/rn-gpui-cmake/x86_64-unknown-linux-gnu/Release/build/rpath_dirs-Release.txt',
`${cache}\n`,
);
expect(resolveLinkedLibraryForTest('libjsi.so', root)).toBe(library);
});
describe('a package only ships libraries from its own release build', () => {
// A clean runner has one config, so this cannot show up on CI. A developer's
// tree accumulates Debug, Release and both -prebuilt variants; the first match
// used to win and Debug sorts first, so a release package could pick up a Debug
// library, or one built against the other runtime.
function projectWithBothConfigs() {
const root = createProject({
files: {
'desktop/.cargo/config.toml': '[build]\ntarget-dir = "build/target"\n',
'desktop/Cargo.toml': '[package]\nname = "my-app"\nversion = "0.1.0"\n',
// A Debug library, and a Release one, at the same relative depth.
'desktop/build/target/rn-gpui-cmake/x86_64-unknown-linux-gnu/Debug/build/ReactCommon/jsi/libjsi.so':
'debug',
'desktop/build/target/rn-gpui-cmake/x86_64-unknown-linux-gnu/Release/build/ReactCommon/jsi/libjsi.so':
'release',
},
});
return root;
}
test('the Debug copy is not found', () => {
const root = projectWithBothConfigs();
const found = resolveLinkedLibraryForTest('libjsi.so', root);
expect(found).toBeDefined();
expect(found).toContain('Release');
expect(found).not.toContain('Debug');
});
test('every directory offered to the resolver is a release config', () => {
const root = projectWithBothConfigs();
for (const directory of releaseLibraryDirectories(root)) {
expect(directory).not.toMatch(/[/\\]Debug/u);
}
});
test('Release-prebuilt outranks Release', () => {
const root = createProject({
files: {
'desktop/.cargo/config.toml': '[build]\ntarget-dir = "build/target"\n',
'desktop/build/target/rn-gpui-cmake/x86_64-unknown-linux-gnu/Release/build/rpath_dirs-Release.txt':
'/from/release\n',
'desktop/build/target/rn-gpui-cmake/x86_64-unknown-linux-gnu/Release-prebuilt/build/rpath_dirs-Release.txt':
'/from/release-prebuilt\n',
},
});
expect(recordedLibraryDirectories(root)[0]).toBe('/from/release-prebuilt');
});
});
test('a library reached by two paths is shipped once', () => {
// The prebuilt cache and the CMake tree can both hold the same engine library.
// The packaging configs map each entry to a destination by file name, so a
// second entry would target the same file twice.
const root = createProject({
files: {
'desktop/.cargo/config.toml': '[build]\ntarget-dir = "build/target"\n',
'desktop/Cargo.toml': '[package]\nname = "my-app"\nversion = "0.1.0"\n',
'desktop/target/release/libfoo.dylib': 'x',
'desktop/build/target/release/libfoo.dylib': 'x',
},
});
const names = runtimeLibraries(root, { os: 'darwin', arch: 'arm64' }).map(
file => path.basename(file),
);
expect(names.filter(name => name === 'libfoo.dylib')).toHaveLength(1);
});
+386
View File
@@ -0,0 +1,386 @@
/**
* Keeps a Cargo target directory from growing without bound.
*
* Cargo never deletes anything from a target directory on its own. Every change
* that alters a unit's hash — a feature toggled, a dependency bumped, a new
* toolchain, an environment variable a build script tracks — leaves the old
* unit beside the new one, and incremental caches stay behind for crates that
* are no longer rebuilt. Over weeks of development that is tens of gigabytes.
*
* So the CLI records which units each build used — Cargo reports every one,
* fresh or rebuilt, as JSON — and before a build removes units no recorded build
* has used for a while. Access times cannot stand in for the record: Windows
* does not update them by default, and a fresh unit is never written to.
*
* A unit with no record — built by a plain `cargo` command, or by a workspace
* that shares the directory without going through this CLI — is only removed
* once a newer unit of the same crate and kind has replaced it, so a build the
* CLI never saw is not thrown away just for being old.
*
* @format
*/
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { execFileSync, spawn } from 'node:child_process';
export const DEFAULT_MAX_AGE_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* The target directory Cargo will build `manifest` into, or `null`.
*
* Asked of Cargo rather than worked out here: `CARGO_TARGET_DIR`, the crate's
* own `.cargo/config.toml` and any parent directory's config all decide it.
*/
export function cargoTargetDirectory(manifest, { cwd, env } = {}) {
try {
const metadata = JSON.parse(
execFileSync(
'cargo',
[
'metadata',
'--no-deps',
'--format-version',
'1',
'--manifest-path',
manifest,
],
{
cwd,
env,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 64 * 1024 * 1024,
},
),
);
return path.resolve(metadata.target_directory);
} catch {
return null;
}
}
/** Where the record of used units lives, inside the target directory. */
export const USAGE_FILE = '.rn-gpui-target-usage.json';
/** `<package>-<hash>`, as Cargo names a unit's fingerprint and build directory. */
const UNIT_DIR = /^(.+)-([0-9a-f]{16})$/u;
/** The hash in a file under `deps/`: `libfoo-<hash>.rlib`, `foo-<hash>.exe`. */
const HASHED_FILE = /-([0-9a-f]{16})(?:\.|$)/u;
/** Whether the collector runs: never in CI, and never when turned off. */
export function targetGcEnabled(env = process.env) {
return !env.CI && env.RN_GPUI_TARGET_GC !== '0';
}
/** How many days an unused unit is kept, from `RN_GPUI_TARGET_GC_DAYS`. */
export function targetGcMaxAgeDays(env = process.env) {
const days = Number(env.RN_GPUI_TARGET_GC_DAYS);
return Number.isFinite(days) && days > 0 ? days : DEFAULT_MAX_AGE_DAYS;
}
/**
* The usage key of the unit a build output belongs to, or `null`.
*
* Keyed by the profile directory and the hash, which together name one unit:
* `debug/0123456789abcdef`. The hash is shared by the unit's fingerprint
* directory, its `build/` directory and its files in `deps/`.
*/
export function unitKey(targetDir, file) {
const parts = path.relative(targetDir, file).split(/[\\/]/u);
const at = parts.findIndex(part =>
['deps', 'build', 'examples', '.fingerprint'].includes(part),
);
if (at <= 0 || at + 1 >= parts.length) return null;
const profile = parts.slice(0, at).join('/');
const entry = parts[at + 1];
const match =
parts[at] === 'build' || parts[at] === '.fingerprint'
? UNIT_DIR.exec(entry)
: HASHED_FILE.exec(entry);
if (!match) return null;
const hash = match[match.length - 1];
return `${profile}/${hash}`;
}
function readUsage(targetDir) {
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(targetDir, USAGE_FILE), 'utf8'),
);
if (parsed && typeof parsed.units === 'object') return parsed;
} catch {}
return { version: 1, units: {} };
}
function writeUsage(targetDir, usage) {
try {
fs.writeFileSync(path.join(targetDir, USAGE_FILE), JSON.stringify(usage));
} catch {
// The record only makes pruning more precise; a build never fails over it.
}
}
/** Marks every unit the given build outputs belong to as used `now`. */
export function recordUsage(targetDir, files, now = Date.now()) {
if (!fs.existsSync(targetDir)) return;
const usage = readUsage(targetDir);
for (const file of files) {
const key = unitKey(targetDir, file);
if (key) usage.units[key] = now;
}
writeUsage(targetDir, usage);
}
/**
* The profile directories in a target directory: each one holds `.fingerprint`.
* A host build puts them at `<target>/<profile>`, a cross build at
* `<target>/<triple>/<profile>`.
*/
function profileDirs(targetDir) {
const found = [];
const visit = (relative, depth) => {
const absolute = path.join(targetDir, relative);
if (fs.existsSync(path.join(absolute, '.fingerprint'))) {
found.push(relative);
return;
}
if (depth === 0) return;
let entries = [];
try {
entries = fs.readdirSync(absolute, { withFileTypes: true });
} catch {}
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
visit(path.join(relative, entry.name), depth - 1);
}
}
};
visit('', 2);
return found.filter(Boolean);
}
/** The newest modification time of a directory and its direct children. */
function newestMtime(dir) {
let newest = 0;
try {
newest = fs.statSync(dir).mtimeMs;
for (const entry of fs.readdirSync(dir)) {
try {
newest = Math.max(newest, fs.statSync(path.join(dir, entry)).mtimeMs);
} catch {}
}
} catch {}
return newest;
}
/**
* The kind of a unit, from its fingerprint: `lib-foo`,
* `build-script-build-script-build`, `run-build-script-build-script-build`.
* One package has several units of different kinds, none replacing another.
*/
function unitKind(fingerprintDir) {
try {
const json = fs
.readdirSync(fingerprintDir)
.find(name => name.endsWith('.json'));
return json ? json.slice(0, -'.json'.length) : '';
} catch {
return '';
}
}
/**
* What a prune would remove: units and incremental caches, without touching
* anything. Pure apart from reading, so it is what the tests exercise.
*/
export function findStale(
targetDir,
{ now = Date.now(), maxAgeDays = DEFAULT_MAX_AGE_DAYS } = {},
) {
const maxAge = maxAgeDays * DAY_MS;
const usage = readUsage(targetDir);
const units = [];
const incremental = [];
for (const profile of profileDirs(targetDir)) {
const profileKey = profile.split(path.sep).join('/');
const fingerprints = path.join(targetDir, profile, '.fingerprint');
const groups = new Map();
for (const dirName of fs.readdirSync(fingerprints)) {
const match = UNIT_DIR.exec(dirName);
if (!match) continue;
const fingerprintDir = path.join(fingerprints, dirName);
const key = `${profileKey}/${match[2]}`;
const recorded = usage.units[key];
const unit = {
profile,
dirName,
hash: match[2],
key,
recorded,
lastUsed: recorded ?? newestMtime(fingerprintDir),
};
const group = `${match[1]}\u0000${unitKind(fingerprintDir)}`;
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(unit);
}
for (const group of groups.values()) {
const newest = Math.max(...group.map(unit => unit.lastUsed));
for (const unit of group) {
if (now - unit.lastUsed <= maxAge) continue;
// A recorded unit that no build has used for the whole period is
// stale. One without a record is only stale once something newer of
// the same crate and kind has taken its place.
if (unit.recorded != null || unit.lastUsed < newest) units.push(unit);
}
}
const incrementalDir = path.join(targetDir, profile, 'incremental');
let caches = [];
try {
caches = fs.readdirSync(incrementalDir);
} catch {}
for (const cache of caches) {
const cacheDir = path.join(incrementalDir, cache);
// A session directory is written each time the crate is compiled, so a
// cache untouched for the period gives the next build nothing to reuse.
if (now - newestMtime(cacheDir) > maxAge) incremental.push(cacheDir);
}
}
return { units, incremental };
}
function sizeOf(target) {
let total = 0;
try {
const stat = fs.lstatSync(target);
if (!stat.isDirectory()) return stat.size;
for (const entry of fs.readdirSync(target)) {
total += sizeOf(path.join(target, entry));
}
} catch {}
return total;
}
function remove(target) {
const size = sizeOf(target);
try {
fs.rmSync(target, { recursive: true, force: true });
return size;
} catch {
return 0;
}
}
/** Removes one unit: its fingerprint, its `build/` directory and its files. */
function removeUnit(targetDir, unit) {
const profileDir = path.join(targetDir, unit.profile);
let freed = remove(path.join(profileDir, '.fingerprint', unit.dirName));
freed += remove(path.join(profileDir, 'build', unit.dirName));
for (const folder of ['deps', 'examples']) {
const dir = path.join(profileDir, folder);
let entries = [];
try {
entries = fs.readdirSync(dir);
} catch {}
for (const entry of entries) {
const match = HASHED_FILE.exec(entry);
if (match && match[1] === unit.hash)
freed += remove(path.join(dir, entry));
}
}
return freed;
}
/**
* Removes what `findStale` reports, and says how much it freed. Quiet when
* there was nothing to remove, so an ordinary build prints nothing extra.
*/
export function pruneTarget(
targetDir,
{
now = Date.now(),
maxAgeDays = DEFAULT_MAX_AGE_DAYS,
log = message => console.log(message),
} = {},
) {
if (!fs.existsSync(targetDir)) return { units: 0, caches: 0, bytes: 0 };
const stale = findStale(targetDir, { now, maxAgeDays });
let bytes = 0;
for (const unit of stale.units) bytes += removeUnit(targetDir, unit);
for (const cache of stale.incremental) bytes += remove(cache);
if (stale.units.length > 0) {
const usage = readUsage(targetDir);
for (const unit of stale.units) delete usage.units[unit.key];
writeUsage(targetDir, usage);
}
if (bytes > 0) {
log(
`Removed ${stale.units.length} build unit(s) and ` +
`${stale.incremental.length} incremental cache(s) unused for ` +
`${maxAgeDays} days from ${targetDir} ` +
`(${(bytes / 1024 ** 3).toFixed(2)} GB). ` +
'Set RN_GPUI_TARGET_GC=0 to keep them.',
);
}
return {
units: stale.units.length,
caches: stale.incremental.length,
bytes,
};
}
/**
* Runs a Cargo build and records the units it used.
*
* `json-render-diagnostics` leaves the diagnostics to Cargo, rendered on stderr
* as usual, and puts only the build messages on stdout as JSON; anything on
* stdout that is not one of them — a build script's own output — is passed
* through. Resolves when Cargo succeeds and rejects when it fails, like the
* plain invocation it replaces.
*/
export function cargoBuildRecording(args, { cwd, env, targetDir }) {
const [command, ...rest] = args;
const cargoArgs = [
command,
'--message-format=json-render-diagnostics',
...rest,
];
return new Promise((resolve, reject) => {
const child = spawn('cargo', cargoArgs, {
cwd,
env,
stdio: ['inherit', 'pipe', 'inherit'],
});
const used = [];
const lines = readline.createInterface({ input: child.stdout });
lines.on('line', line => {
let message = null;
if (line.startsWith('{')) {
try {
message = JSON.parse(line);
} catch {}
}
if (message == null || typeof message.reason !== 'string') {
process.stdout.write(`${line}\n`);
return;
}
if (message.reason === 'compiler-artifact') {
used.push(...(message.filenames ?? []));
if (message.executable) used.push(message.executable);
} else if (message.reason === 'build-script-executed') {
if (message.out_dir) used.push(message.out_dir);
}
});
child.on('error', reject);
child.on('close', code => {
if (code !== 0) {
reject(new Error(`cargo ${cargoArgs.join(' ')} failed with ${code}`));
return;
}
if (targetDir) recordUsage(targetDir, used);
resolve();
});
});
}
@@ -0,0 +1,224 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import {
USAGE_FILE,
findStale,
pruneTarget,
recordUsage,
targetGcEnabled,
targetGcMaxAgeDays,
unitKey,
} from './target-gc.js';
const DAY = 24 * 60 * 60 * 1000;
const NOW = Date.UTC(2026, 9, 24);
const HASH_A = '0123456789abcdef';
const HASH_B = 'fedcba9876543210';
const HASH_C = '00112233445566ff';
const roots = [];
afterEach(() => {
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
function targetDir() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-gpui-target-gc-'));
roots.push(root);
return root;
}
function write(file, contents = 'x') {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
}
function age(target, daysOld) {
const time = new Date(NOW - daysOld * DAY);
fs.utimesSync(target, time, time);
}
/**
* One unit as Cargo lays it out: a fingerprint whose `.json` names its kind, a
* library in `deps/`, and for a build script its `build/` directory.
*/
function unit(
target,
{ name, hash, kind = `lib-${name}`, daysOld, profile = 'debug' },
) {
const profileDir = path.join(target, profile);
const fingerprint = path.join(profileDir, '.fingerprint', `${name}-${hash}`);
write(path.join(fingerprint, kind));
write(path.join(fingerprint, `${kind}.json`), '{}');
const crate = name.replaceAll('-', '_');
write(path.join(profileDir, 'deps', `lib${crate}-${hash}.rlib`), 'rlib');
write(path.join(profileDir, 'deps', `${crate}-${hash}.d`));
if (kind.includes('build-script')) {
write(
path.join(profileDir, 'build', `${name}-${hash}`, 'out', 'generated.rs'),
);
}
for (const entry of fs.readdirSync(fingerprint)) {
age(path.join(fingerprint, entry), daysOld);
}
age(fingerprint, daysOld);
return { profileDir, fingerprint, crate };
}
describe('unitKey', () => {
test('names a unit by its profile and hash, from any of its outputs', () => {
const target = targetDir();
expect(
unitKey(
target,
path.join(target, 'debug', 'deps', `libserde-${HASH_A}.rlib`),
),
).toBe(`debug/${HASH_A}`);
expect(
unitKey(
target,
path.join(target, 'debug', 'build', `rn-gpui-sys-${HASH_B}`, 'out'),
),
).toBe(`debug/${HASH_B}`);
// A cross build nests the profile under the target triple.
expect(
unitKey(
target,
path.join(
target,
'x86_64-pc-windows-msvc',
'release',
'deps',
`foo-${HASH_C}.dll`,
),
),
).toBe(`x86_64-pc-windows-msvc/release/${HASH_C}`);
});
test('an uplifted binary belongs to no unit', () => {
const target = targetDir();
expect(unitKey(target, path.join(target, 'debug', 'app.exe'))).toBeNull();
});
});
describe('pruning', () => {
test('a recorded unit no build used for the period is removed, with its files', () => {
const target = targetDir();
const old = unit(target, { name: 'serde', hash: HASH_A, daysOld: 30 });
unit(target, { name: 'log', hash: HASH_B, daysOld: 30 });
recordUsage(
target,
[path.join(old.profileDir, 'deps', `libserde-${HASH_A}.rlib`)],
NOW - 10 * DAY,
);
recordUsage(
target,
[path.join(old.profileDir, 'deps', `liblog-${HASH_B}.rlib`)],
NOW - DAY,
);
const result = pruneTarget(target, {
now: NOW,
maxAgeDays: 7,
log: () => {},
});
expect(result.units).toBe(1);
expect(fs.existsSync(old.fingerprint)).toBe(false);
expect(
fs.existsSync(
path.join(old.profileDir, 'deps', `libserde-${HASH_A}.rlib`),
),
).toBe(false);
expect(
fs.existsSync(path.join(old.profileDir, 'deps', `serde-${HASH_A}.d`)),
).toBe(false);
// Used yesterday, so kept, and its record survives.
expect(
fs.existsSync(path.join(old.profileDir, 'deps', `liblog-${HASH_B}.rlib`)),
).toBe(true);
const usage = JSON.parse(
fs.readFileSync(path.join(target, USAGE_FILE), 'utf8'),
);
expect(Object.keys(usage.units)).toEqual([`debug/${HASH_B}`]);
});
test('an old unit without a record is kept while it is the only one of its kind', () => {
// Built by a plain `cargo` command the CLI never saw: old is not enough.
const target = targetDir();
unit(target, { name: 'gpui', hash: HASH_A, daysOld: 60 });
expect(findStale(target, { now: NOW, maxAgeDays: 7 }).units).toEqual([]);
});
test('an old unit without a record goes once a newer one of the same kind replaced it', () => {
const target = targetDir();
unit(target, { name: 'rn-gpui-sys', hash: HASH_A, daysOld: 30 });
unit(target, { name: 'rn-gpui-sys', hash: HASH_B, daysOld: 1 });
const stale = findStale(target, { now: NOW, maxAgeDays: 7 });
expect(stale.units.map(staleUnit => staleUnit.hash)).toEqual([HASH_A]);
});
test("a package's build-script units are not replaced by its library", () => {
// One package has a library, a build script and the build script's run,
// each its own unit. The library being newer replaces none of the others.
const target = targetDir();
const name = 'rn-gpui-sys';
unit(target, {
name,
hash: HASH_A,
daysOld: 30,
kind: 'build-script-build-script-build',
});
unit(target, {
name,
hash: HASH_B,
daysOld: 30,
kind: 'run-build-script-build-script-build',
});
unit(target, { name, hash: HASH_C, daysOld: 1 });
expect(findStale(target, { now: NOW, maxAgeDays: 7 }).units).toEqual([]);
});
test('an incremental cache untouched for the period is removed', () => {
const target = targetDir();
unit(target, { name: 'app', hash: HASH_A, daysOld: 1 });
const incremental = path.join(target, 'debug', 'incremental');
write(path.join(incremental, 'app-old', 's-session', 'query-cache.bin'));
age(path.join(incremental, 'app-old', 's-session'), 20);
age(path.join(incremental, 'app-old'), 20);
write(path.join(incremental, 'app-new', 's-session', 'query-cache.bin'));
age(path.join(incremental, 'app-new', 's-session'), 0);
age(path.join(incremental, 'app-new'), 0);
pruneTarget(target, { now: NOW, maxAgeDays: 7, log: () => {} });
expect(fs.existsSync(path.join(incremental, 'app-old'))).toBe(false);
expect(fs.existsSync(path.join(incremental, 'app-new'))).toBe(true);
});
test('a missing target directory is a no-op', () => {
const target = path.join(targetDir(), 'never-built');
expect(pruneTarget(target, { now: NOW, log: () => {} })).toEqual({
units: 0,
caches: 0,
bytes: 0,
});
});
});
describe('configuration', () => {
test('never runs in CI, and can be turned off', () => {
expect(targetGcEnabled({})).toBe(true);
expect(targetGcEnabled({ CI: 'true' })).toBe(false);
expect(targetGcEnabled({ RN_GPUI_TARGET_GC: '0' })).toBe(false);
});
test('keeps units for a week unless told otherwise', () => {
expect(targetGcMaxAgeDays({})).toBe(7);
expect(targetGcMaxAgeDays({ RN_GPUI_TARGET_GC_DAYS: '14' })).toBe(14);
expect(targetGcMaxAgeDays({ RN_GPUI_TARGET_GC_DAYS: 'nope' })).toBe(7);
});
});
@@ -0,0 +1,38 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View testID="desktop-app-root" style={styles.page}>
<Text style={styles.eyebrow}>REACT NATIVE + GPUI</Text>
<Text style={styles.title}>Your desktop app is ready.</Text>
<Text style={styles.body}>
Edit App.tsx and run `npx react-native run-desktop` to launch the GPUI
desktop target.
</Text>
</View>
);
}
const styles = StyleSheet.create({
page: {
flex: 1,
justifyContent: 'center',
padding: 48,
backgroundColor: '#10151d',
},
eyebrow: {
color: '#70d6bd',
fontSize: 13,
fontWeight: '700',
letterSpacing: 2,
},
title: { color: '#f5f7fa', fontSize: 34, fontWeight: '700', marginTop: 16 },
body: {
color: '#bac3cf',
fontSize: 17,
lineHeight: 25,
marginTop: 12,
maxWidth: 520,
},
});
@@ -0,0 +1,10 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
// Rewrites `require` of a `.node` file (directly, or through a loader such
// as `node-gyp-build`, `bindings` or a napi-rs generated file) into the
// path the desktop runtime opens. A no-op on other platforms, so the same
// config still builds for iOS and Android.
'module:@react-native-gpui/core/babel-plugin-node-addons',
],
};
@@ -0,0 +1,6 @@
[build]
target-dir = "build/target"
[env]
RN_GPUI_AUTOLINK_MANIFEST = { value = "autolink/autolink.json", relative = true }
RN_GPUI_AUTOLINK_CMAKE = { value = "autolink/autolink.cmake", relative = true }
@@ -0,0 +1,128 @@
# Desktop (GPUI)
This is the app-local GPUI target. Its `Cargo.toml` depends on the `react-native-gpui` Rust crate; `src/main.rs` is the app's launch point, so you can add Rust modules and local crate dependencies without editing the platform package. iOS, Android, and desktop share the JavaScript entry point and JS dependencies. The build script reads the app name and `desktop.appId` from the project-root `app.json`.
Run the desktop app from the project root with:
```sh
npx react-native run-desktop
```
The default reuses this project's Metro on port 8081, or starts it in a new
terminal window (`--terminal <app>` or `$REACT_TERMINAL` picks the terminal),
and runs a debug Cargo build, so Fast Refresh and LogBox work. Change the Metro
port with `--port 8082`; `--no-packager` never starts Metro, and
`--packager-log` runs it in the background with its output in
`desktop/build/metro.log`.
Use `--release` to bundle JavaScript and run the optimized, Metro-free build.
`npx react-native init-desktop` installs dependencies, autolinks native modules, and creates `build/index.bundle`; that bundle lets `cargo run` launch the latest initialized app without the CLI. For development with Fast Refresh, LogBox, and a live Metro server, use `npx react-native run-desktop`.
Autolink output is generated under `desktop/autolink/` and gitignored. It is
recreated by `init-desktop` and each `run-desktop`; do not commit those generated
files. Cargo build output and bundles are also kept under `desktop/build/`.
## Window configuration
`src/main.rs` uses `DesktopAppConfig` to set the title and initial size. It can
also set an initial position, configure public GPUI `WindowOptions`, install
GPUI app services before launch, and access the live `gpui::Window` after it
opens. Window size and position restore from the previous run by default; call
`.restore_window_state(false)` to always use the configured initial geometry.
## Project-local Rust TurboModules
A local Rust TurboModule is a spec file plus a `mod` that implements the trait
generated from it:
1. Add `src/NativeGreeting.ts` with a
`TurboModuleRegistry.getEnforcing<Spec>('NativeGreeting')` spec.
2. List it in `desktop/build.rs`:
`rn_gpui_module_build::generate(&["src/NativeGreeting.ts"]);`.
3. Write the implementation in `desktop/src/greeting.rs`, deriving nothing by
hand: the generated file gives you `NativeGreetingSpec`, the events handle and
the value structs.
```rust
use rn_gpui_module::{ModuleError, ModuleThread, Value};
pub struct Greeting;
impl NativeGreetingSpec for Greeting {
fn new(_events: NativeGreetingEvents) -> Self {
Self
}
fn greet(&self, name: String) -> Result<String, ModuleError> {
Ok(format!("Hello from Rust, {name}!"))
}
}
```
A method that returns a value runs on the JS thread; a method that returns
`void` or a `Promise` runs on the module's own thread unless the impl adds
`const THREAD: ModuleThread = ModuleThread::Main;`.
4. Declare `mod greeting;` in `src/main.rs` and register it before the app
starts:
```rust
react_native_gpui::register_module(native_greeting_module::<greeting::Greeting>());
```
`include_specs!()` in `main.rs` pulls in the generated traits, structs and
descriptors. A mistake against the spec is a compile error in this crate, not a
runtime surprise. The runtime crate is `rn-gpui-module`, and
`rn-gpui-module-build` runs the generator from `build.rs`.
A Rust module depends on core and on gpui by **version**, never by path:
```toml
[dependencies]
rn-gpui-module = "0.1"
gpui = "0.2"
[build-dependencies]
rn-gpui-module-build = "0.1"
```
Those crates live inside `@react-native-gpui/core`, at a path that only exists
in this repository, so a relative path breaks the moment the package is
installed. Autolink reads core's `Cargo.toml` for the gpui revision and writes a
`[patch.crates-io]` table into the app's `desktop/Cargo.toml`, which is the only
place `[patch]` applies; that table points each crate at the core the app
actually installed and pins gpui to core's revision. It is regenerated on every
autolink run, so edit the version in your own manifest and leave the patch
alone. A path or git dependency on those crates is refused with an explanation
rather than failing later in an installed app.
For a reusable TurboModule library, keep its TypeScript spec under `src/` and
its native implementation under `desktop/`, with `codegenConfig` and
`platforms.desktop` in `package.json`; `npx react-native create-library` creates
that layout. Autolink runs React Native Codegen over the package's specs and
writes `<codegenConfig.name>JSI.h` plus `<codegenConfig.name>.h`, the latter
declaring one spec class per spec file (`src/NativeGreeting.ts` defines
`NativeGreetingSpec`). A C++ implementation derives from that class and
declares one method per spec method, with typed arguments (`std::string`,
`double`, `bool`, `folly::dynamic`, `std::vector<T>`, `std::optional<T>`,
`rngpui::Callback<...>`) and either a return value or, for a spec `Promise<T>`,
a `void` method taking `rngpui::Promise<T>` last. `react/bridging` performs the
conversion, so a signature that disagrees with the spec fails to compile.
Methods that return a value run on the JS thread and take `jsi::Runtime&`
first. Methods that return `void` or a `Promise` run on the module's own thread
by default, so they never block the JS thread; set
a `public static constexpr rngpui::ModuleThread kThread` to `Main` (the GPUI
main thread) or `JS` (inline on the JS thread, taking `jsi::Runtime&` first) to
choose another thread; the generated spec class reads `T::kThread`, so it has to
be public. A method that does not run on the JS thread cannot take
a JSI type. A spec `readonly onProgress: CodegenTypes.EventEmitter<number>`
generates a protected `emitOnProgress(value)` that any thread may call, and
`sendEvent("name", body)` covers modules used through `NativeEventEmitter`.
`desktop/desktop.json` is the app's own configuration: its name, identifier,
version, icon, window and how it is packaged. Its schema is
`@react-native-gpui/cli/desktop.schema.json`, so an editor completes it. A
`desktop.<os>.json` beside it is merged over the base for that OS. The
`Info.plist` is generated from these settings rather than hand-written, and
`npx react-native package-desktop` builds the installers from the same file.
@@ -0,0 +1,4 @@
# Desktop assets
Point `desktop.json`'s `icon` at one 1024×1024 PNG; the per-OS formats (`.icns`,
`.ico`, PNG sizes) are generated from it when the app is packaged.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Some files were not shown because too many files have changed in this diff Show More