@react-native-gpui/fs 0.0.0-stage → 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ //! Shared state and helpers for the file system module.
2
+ //!
3
+ //! The trait's methods are split across sibling files by concern — `files`,
4
+ //! `dirs`, `handles`, `paths`, `dialogs`, `watching` — because they are large and
5
+ //! largely independent. Rust allows the trait to be implemented in several files
6
+ //! of one crate, so each file owns its own `impl` block.
7
+ //!
8
+ //! Everything they share is here: the open file table, the flag translation, and
9
+ //! the conversions the bridge's `f64` arguments need.
10
+
11
+ use std::fs::File;
12
+ use std::sync::atomic::AtomicU32;
13
+ use std::sync::Mutex;
14
+
15
+ use rn_gpui_module::{ModuleError, ModuleThread};
16
+
17
+ use crate::NativeFsEvents;
18
+
19
+ use crate::errors;
20
+
21
+ /// Flags, as `node:fs` numbers them: Node's table, not the platform's, so these
22
+ /// are translated rather than passed through to `OpenOptions`.
23
+ pub mod flags {
24
+ pub const RDONLY: i32 = 0;
25
+ pub const WRONLY: i32 = 1;
26
+ pub const RDWR: i32 = 2;
27
+ pub const CREAT: i32 = 64;
28
+ pub const EXCL: i32 = 128;
29
+ pub const TRUNC: i32 = 512;
30
+ pub const APPEND: i32 = 1024;
31
+ }
32
+
33
+ /// Access modes for `access`, matching `node:fs.constants`.
34
+ pub mod access {
35
+ pub const F_OK: i32 = 0;
36
+ pub const X_OK: i32 = 1;
37
+ pub const W_OK: i32 = 2;
38
+ pub const R_OK: i32 = 4;
39
+ }
40
+
41
+ /// The kind numbers the JS layer turns into `Stats` predicates.
42
+ pub mod kind {
43
+ pub const FILE: f64 = 0.0;
44
+ pub const DIRECTORY: f64 = 1.0;
45
+ pub const SYMLINK: f64 = 2.0;
46
+ pub const OTHER: f64 = 3.0;
47
+ }
48
+
49
+ pub struct RNFsModule {
50
+ pub events: NativeFsEvents,
51
+ /// Open files, keyed by the number JS holds. The table belongs to the
52
+ /// instance, so a reload closes every descriptor when it drops.
53
+ ///
54
+ /// Each entry records whether the descriptor was opened writable. The OS
55
+ /// reports a write to a read-only handle as `EBADF` on Unix and `EACCES` on
56
+ /// Windows, and Node reports `EBADF` on both, so the answer is taken from what
57
+ /// the handle was opened with rather than from the errno.
58
+ pub files: Mutex<std::collections::HashMap<u32, (File, bool)>>,
59
+ /// Never reused, so a stale `fd` from JS fails instead of closing an
60
+ /// unrelated file.
61
+ pub next_fd: AtomicU32,
62
+ /// Live watchers, keyed the same way.
63
+ pub watchers: Mutex<std::collections::HashMap<u32, crate::watching::Watcher>>,
64
+ pub next_watcher: AtomicU32,
65
+ }
66
+
67
+ impl RNFsModule {
68
+ pub fn create(events: NativeFsEvents) -> Self {
69
+ Self {
70
+ events,
71
+ files: Mutex::new(std::collections::HashMap::new()),
72
+ next_fd: AtomicU32::new(1),
73
+ watchers: Mutex::new(std::collections::HashMap::new()),
74
+ next_watcher: AtomicU32::new(1),
75
+ }
76
+ }
77
+
78
+ pub fn take_fd(&self) -> u32 {
79
+ self.next_fd
80
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
81
+ }
82
+
83
+ pub fn take_watcher_id(&self) -> u32 {
84
+ self.next_watcher
85
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
86
+ }
87
+
88
+ /// A clone of an open file, sharing its offset — which is what a descriptor
89
+ /// is. Holding the lock only for the lookup keeps a slow write from blocking
90
+ /// every other call on this thread.
91
+ pub fn file(&self, fd: f64) -> Result<File, ModuleError> {
92
+ self.file_and_writability(fd).map(|(file, _)| file)
93
+ }
94
+
95
+ /// The handle and whether it can be written to.
96
+ pub fn file_and_writability(&self, fd: f64) -> Result<(File, bool), ModuleError> {
97
+ let id = descriptor(fd)?;
98
+ let files = self.files.lock().unwrap_or_else(|e| e.into_inner());
99
+ let (file, writable) = files.get(&id).ok_or_else(|| bad_fd(id))?;
100
+ let cloned = file
101
+ .try_clone()
102
+ .map_err(|error| errors::from_io(error, &id.to_string()))?;
103
+ Ok((cloned, *writable))
104
+ }
105
+ }
106
+
107
+ /// The error Node raises for a descriptor it does not know.
108
+ pub fn bad_fd(id: u32) -> ModuleError {
109
+ errors::invalid("EBADF", format!("EBADF: bad file descriptor, {id}"))
110
+ }
111
+
112
+ /// A descriptor from the bridge. Anything but a non-negative integer is a
113
+ /// programmer error, so it reports as a bad descriptor rather than a file error.
114
+ pub fn descriptor(value: f64) -> Result<u32, ModuleError> {
115
+ if value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= u32::MAX as f64 {
116
+ Ok(value as u32)
117
+ } else {
118
+ Err(bad_fd(0))
119
+ }
120
+ }
121
+
122
+ /// A byte length from the bridge.
123
+ pub fn length(value: f64) -> Result<usize, ModuleError> {
124
+ if value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= u32::MAX as f64 {
125
+ Ok(value as usize)
126
+ } else {
127
+ Err(errors::invalid(
128
+ "EINVAL",
129
+ format!("EINVAL: invalid argument, length {value}"),
130
+ ))
131
+ }
132
+ }
133
+
134
+ /// A permission bitmask, clamped to the bits `std::fs` accepts. Zero means the
135
+ /// caller passed nothing, which Node treats as `0o666`.
136
+ pub fn mode(value: f64) -> u32 {
137
+ if value.is_finite() && value > 0.0 {
138
+ value as u32 & 0o7777
139
+ } else {
140
+ 0o666
141
+ }
142
+ }
143
+
144
+ /// `node:fs` numeric flags as `OpenOptions`.
145
+ pub fn open_options(flags_value: f64, mode: u32) -> std::fs::OpenOptions {
146
+ let flags_value = flags_value as i32;
147
+ let mut options = std::fs::OpenOptions::new();
148
+ // Bit tests, not `matches!`: `matches!(value, A | B)` is the or-pattern
149
+ // `value == A || value == B`, which for Node's flags asks whether the whole
150
+ // mask equals 0 or 2. `'w'` is `WRONLY | CREAT | TRUNC` (577), so that test
151
+ // was false and every write opened read-only and failed `EINVAL`.
152
+ // `RDONLY` is zero, so "read" is the absence of `WRONLY` rather than a bit.
153
+ options
154
+ .read(flags_value & flags::WRONLY == 0)
155
+ .write(flags_value & (flags::WRONLY | flags::RDWR) != 0)
156
+ .create(flags_value & flags::CREAT != 0)
157
+ .create_new(flags_value & flags::EXCL != 0)
158
+ .truncate(flags_value & flags::TRUNC != 0)
159
+ .append(flags_value & flags::APPEND != 0);
160
+ #[cfg(unix)]
161
+ {
162
+ use std::os::unix::fs::OpenOptionsExt as _;
163
+ options.mode(mode);
164
+ }
165
+ #[cfg(not(unix))]
166
+ let _ = mode;
167
+ options
168
+ }
169
+
170
+ /// Milliseconds since the epoch, as the bridge sends them.
171
+ pub fn system_time(ms: f64) -> std::time::SystemTime {
172
+ if ms.is_finite() && ms >= 0.0 {
173
+ std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64)
174
+ } else {
175
+ std::time::UNIX_EPOCH
176
+ }
177
+ }
178
+
179
+ /// A `SystemTime` as milliseconds, which is what `StatResult` carries.
180
+ pub fn millis(time: std::time::SystemTime) -> f64 {
181
+ time.duration_since(std::time::UNIX_EPOCH)
182
+ .map(|elapsed| elapsed.as_millis() as f64)
183
+ .unwrap_or(0.0)
184
+ }
185
+
186
+ // The spec says `THREAD = Own`; this keeps the module compiled against that.
187
+ const _: ModuleThread = ModuleThread::Own;
@@ -0,0 +1,231 @@
1
+ //! The well-known folders, as one `KnownPaths` object.
2
+ //!
3
+ //! `dirs` answers for the folders every platform has. The per-application three
4
+ //! — `appData`, `appCache`, `appLogs` — are this module's own convention, because
5
+ //! the host does not yet tell modules which app they belong to; see
6
+ //! `app_id` below. All three are created on the first read, as the design asks,
7
+ //! so a caller can write a log file without a `mkdir` first.
8
+
9
+ use std::path::{Path, PathBuf};
10
+
11
+ use rn_gpui_module::ModuleError;
12
+
13
+ use crate::KnownPaths;
14
+
15
+ /// The public folders plus the three per-application ones, created if needed.
16
+ ///
17
+ /// Nothing here fails: a folder that does not exist is reported as the temp
18
+ /// directory instead. The design treats these as conveniences, and a caller
19
+ /// asking where to put a log file is better served by a working stand-in than by
20
+ /// a thrown error it cannot recover from — the alternative is a crash on a
21
+ /// system where, say, `Desktop` was never created.
22
+ pub fn known_paths() -> Result<KnownPaths, ModuleError> {
23
+ let temp = std::env::temp_dir();
24
+ let id = app_id();
25
+
26
+ let app_data = dirs::data_dir().map(|dir| dir.join(&id));
27
+ let app_cache = dirs::cache_dir().map(|dir| dir.join(&id));
28
+ let app_logs = logs_dir(&id);
29
+
30
+ Ok(KnownPaths {
31
+ home: fallback(dirs::home_dir(), &temp),
32
+ documents: fallback(dirs::document_dir(), &temp),
33
+ downloads: fallback(dirs::download_dir(), &temp),
34
+ desktop: fallback(dirs::desktop_dir(), &temp),
35
+ temp: path_string(&temp),
36
+ app_data: ensure_directory(app_data, &temp),
37
+ app_cache: ensure_directory(app_cache, &temp),
38
+ app_logs: ensure_directory(app_logs, &temp),
39
+ })
40
+ }
41
+
42
+ /// A folder as a string, with the temp directory standing in when the platform
43
+ /// cannot name it.
44
+ fn fallback(found: Option<PathBuf>, temp: &Path) -> String {
45
+ path_string(found.as_deref().unwrap_or(temp))
46
+ }
47
+
48
+ /// A path as `KnownPaths` carries it. A non-UTF-8 path cannot be a JS string, so
49
+ /// the lossy form is used rather than failing: it is still a path the platform
50
+ /// will resolve, and the folder is only a suggestion to the caller.
51
+ fn path_string(path: &Path) -> String {
52
+ path.to_string_lossy().into_owned()
53
+ }
54
+
55
+ /// Creates a directory that the returned path promises exists, falling back to
56
+ /// the temp directory if it cannot be created.
57
+ ///
58
+ /// A failure here means the platform gave no base directory at all, or that it
59
+ /// exists but is not writable; temp is the one directory a process can normally
60
+ /// count on, so the caller still gets somewhere to write.
61
+ fn ensure_directory(found: Option<PathBuf>, temp: &Path) -> String {
62
+ let Some(directory) = found else {
63
+ return path_string(temp);
64
+ };
65
+ match std::fs::create_dir_all(&directory) {
66
+ Ok(()) => path_string(&directory),
67
+ Err(_) => path_string(temp),
68
+ }
69
+ }
70
+
71
+ /// Where `appLogs` lives, which is the one app folder whose convention differs
72
+ /// by platform: macOS keeps logs under `~/Library/Logs`, the others under the
73
+ /// data directory. Both get the app id as their last component so two apps do
74
+ /// not share a log folder.
75
+ fn logs_dir(id: &str) -> Option<PathBuf> {
76
+ #[cfg(target_os = "macos")]
77
+ {
78
+ dirs::home_dir().map(|home| home.join("Library").join("Logs").join(id))
79
+ }
80
+ #[cfg(not(target_os = "macos"))]
81
+ {
82
+ dirs::data_dir().map(|dir| dir.join(id).join("logs"))
83
+ }
84
+ }
85
+
86
+ /// The id the three per-app folders are namespaced by.
87
+ ///
88
+ /// This is a **placeholder**. The design wants these paths to be per-app and
89
+ /// says the host already knows the id (`DesktopAppConfig::app_id`), but no host
90
+ /// function exposes it to modules yet: `host_info().app_id` does not exist. Until
91
+ /// that API lands, `RN_GPUI_APP_ID` is honoured when the app sets it, and the
92
+ /// executable's file stem is the fallback — stable for a given binary, which is
93
+ /// enough to keep the folders apart and across runs.
94
+ ///
95
+ /// When `host_info().app_id` is added, this function should read it and the two
96
+ /// fallbacks should go, because a renamed binary would otherwise move the app's
97
+ /// data and cache out from under the user.
98
+ fn app_id() -> String {
99
+ let from_environment = std::env::var("RN_GPUI_APP_ID").ok();
100
+ let from_executable = std::env::current_exe().ok().and_then(|exe| {
101
+ exe.file_stem()
102
+ .map(|stem| stem.to_string_lossy().into_owned())
103
+ });
104
+ pick_app_id(from_environment.as_deref(), from_executable.as_deref())
105
+ }
106
+
107
+ /// The selection half of `app_id`, split out so the precedence and the fallback
108
+ /// can be tested without touching the environment, which other tests share.
109
+ fn pick_app_id(from_environment: Option<&str>, from_executable: Option<&str>) -> String {
110
+ [from_environment, from_executable]
111
+ .into_iter()
112
+ .flatten()
113
+ .map(sanitise)
114
+ .find(|id| !id.is_empty())
115
+ // A process whose executable has no usable name still needs folders, and
116
+ // the name only has to be stable, not accurate.
117
+ .unwrap_or_else(|| "rn-gpui-app".to_string())
118
+ }
119
+
120
+ /// Strips the characters that cannot appear in a directory name on any of the
121
+ /// three platforms, so an id from the environment cannot escape its parent
122
+ /// directory or fail `create_dir_all` on Windows.
123
+ ///
124
+ /// Leading and trailing dots and dashes go too: an id that was only separators
125
+ /// sanitises to nothing rather than to a directory named `-`, and one that began
126
+ /// with `..` cannot end up looking like a parent reference.
127
+ fn sanitise(id: &str) -> String {
128
+ id.trim()
129
+ .chars()
130
+ .map(|character| match character {
131
+ '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '-',
132
+ other => other,
133
+ })
134
+ .collect::<String>()
135
+ .trim_matches(|character| character == '.' || character == '-')
136
+ .to_string()
137
+ }
138
+
139
+ #[cfg(test)]
140
+ mod tests {
141
+ use super::*;
142
+
143
+ #[test]
144
+ fn every_known_path_is_a_non_empty_string() {
145
+ let paths = known_paths().expect("known paths");
146
+ for (name, value) in [
147
+ ("home", &paths.home),
148
+ ("documents", &paths.documents),
149
+ ("downloads", &paths.downloads),
150
+ ("desktop", &paths.desktop),
151
+ ("temp", &paths.temp),
152
+ ("appData", &paths.app_data),
153
+ ("appCache", &paths.app_cache),
154
+ ("appLogs", &paths.app_logs),
155
+ ] {
156
+ assert!(!value.is_empty(), "{name} was empty");
157
+ }
158
+ }
159
+
160
+ #[test]
161
+ fn the_app_folders_exist_after_the_first_read() {
162
+ let paths = known_paths().expect("known paths");
163
+ for (name, value) in [
164
+ ("appData", &paths.app_data),
165
+ ("appCache", &paths.app_cache),
166
+ ("appLogs", &paths.app_logs),
167
+ ] {
168
+ assert!(
169
+ Path::new(value).is_dir(),
170
+ "{name} was not created at {value}"
171
+ );
172
+ }
173
+ }
174
+
175
+ #[test]
176
+ fn the_app_folders_are_distinct() {
177
+ let paths = known_paths().expect("known paths");
178
+ let app_data = Path::new(&paths.app_data);
179
+ let app_cache = Path::new(&paths.app_cache);
180
+ let app_logs = Path::new(&paths.app_logs);
181
+
182
+ assert_ne!(app_data, app_cache);
183
+ assert_ne!(app_data, app_logs);
184
+ assert_ne!(app_cache, app_logs);
185
+ }
186
+
187
+ #[test]
188
+ fn a_second_read_returns_the_same_folders() {
189
+ // Creating a directory that already exists has to keep succeeding, since
190
+ // the JS layer may call `getPaths()` more than once per run.
191
+ let first = known_paths().expect("known paths");
192
+ let second = known_paths().expect("known paths");
193
+ assert_eq!(first.app_data, second.app_data);
194
+ assert_eq!(first.app_cache, second.app_cache);
195
+ assert_eq!(first.app_logs, second.app_logs);
196
+ }
197
+
198
+ #[test]
199
+ fn an_id_from_the_environment_names_the_app_folders() {
200
+ // Only the pure part is asserted: mutating the environment would race
201
+ // with the other tests in this module, which read it concurrently.
202
+ assert_eq!(sanitise("my-app"), "my-app");
203
+ assert_eq!(sanitise(" ../escape "), "escape");
204
+ assert_eq!(sanitise("a/b\\c"), "a-b-c");
205
+ assert_eq!(sanitise(" "), "");
206
+ }
207
+
208
+ #[test]
209
+ fn the_environment_id_wins_over_the_executable_name() {
210
+ assert_eq!(pick_app_id(Some("from-env"), Some("from-exe")), "from-env");
211
+ }
212
+
213
+ #[test]
214
+ fn an_unusable_environment_id_falls_through_to_the_executable_name() {
215
+ assert_eq!(pick_app_id(Some(" "), Some("from-exe")), "from-exe");
216
+ // A separator-only id would escape its parent, which `sanitise` turns
217
+ // into a name rather than a path.
218
+ assert_eq!(pick_app_id(Some("/"), Some("from-exe")), "from-exe");
219
+ }
220
+
221
+ #[test]
222
+ fn no_id_at_all_still_yields_a_stable_name() {
223
+ assert_eq!(pick_app_id(None, None), "rn-gpui-app");
224
+ assert_eq!(pick_app_id(Some(""), Some("")), "rn-gpui-app");
225
+ }
226
+
227
+ #[test]
228
+ fn a_hostile_executable_name_cannot_escape_the_base_directory() {
229
+ assert_eq!(pick_app_id(None, Some("../../etc/passwd")), "etc-passwd");
230
+ }
231
+ }