mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d247815fd | ||
|
|
d785a685a0 | ||
|
|
5518c25ac5 |
@@ -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
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -29,6 +29,13 @@ the titlebar row. Production builds leave the variable unset and show no label.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
## Experimental GPUI client
|
||||
|
||||
The opt-in [GPUI target](../packages/app/gpui/README.md) runs the existing React
|
||||
Native app on Linux. Its contributor guide covers setup, the renderer comparison,
|
||||
unsupported features, native checks, and experiments to pick up. It connects to
|
||||
an already-running daemon and does not replace the Electron dev command.
|
||||
|
||||
## Nix desktop package
|
||||
|
||||
The flake exposes `packages.<system>.desktop` on Linux and macOS:
|
||||
|
||||
+5
-1
@@ -122,7 +122,11 @@
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push",
|
||||
"gpui:install": "node scripts/gpui.mjs install",
|
||||
"gpui:bundle": "node scripts/gpui.mjs bundle",
|
||||
"build:gpui": "node scripts/gpui.mjs build",
|
||||
"dev:gpui": "node scripts/gpui.mjs run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
desktop/build/
|
||||
desktop/autolink/
|
||||
@@ -0,0 +1,3 @@
|
||||
workspaces=false
|
||||
legacy-peer-deps=true
|
||||
install-links=true
|
||||
@@ -0,0 +1,202 @@
|
||||
# Experimental GPUI client
|
||||
|
||||
Run Paseo's existing React Native app in a native GPUI window on Linux. This is
|
||||
a community starting point: the real workspace UI runs, but terminal/diff
|
||||
renderers and several native services still need porting. The normal desktop
|
||||
build remains Electron. No daemon changes or release installers are included.
|
||||
|
||||
## Run it
|
||||
|
||||
Start from the repository's [development setup](../../../docs/development.md).
|
||||
A fresh checkout needs the root dependencies and workspace build outputs:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:server
|
||||
npm run gpui:install
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run dev:gpui
|
||||
```
|
||||
|
||||
Use an already-running compatible daemon and replace `localhost:6768` with its
|
||||
endpoint. These GPUI commands never start or restart a daemon. The address is
|
||||
inlined at bundle time. The app's normal connection/authentication rules apply.
|
||||
|
||||
You also need Rust stable through rustup, CMake, Ninja, Clang, Linux X11/Wayland
|
||||
development libraries, and Vulkan. The tested Linux x86_64 host already had
|
||||
most build tools; it additionally needed `libxkbcommon-x11-dev`. A clean-machine
|
||||
prerequisite check is still needed. The first native build downloads dependencies
|
||||
and builds Rust/C++; allow several minutes and several GB of disk.
|
||||
|
||||
`dev:gpui` bundles the app, autolinks native modules, builds, and opens the window.
|
||||
Restart after editing. It uses an offline production JS bundle; Fast Refresh is
|
||||
not wired into this launcher. `build:gpui` builds without opening a window.
|
||||
`gpui:bundle -- --dev false` only writes the JS bundle, source map, and assets to
|
||||
`.artifacts/gpui/`. Bundle validation checks that the real routes are present and
|
||||
that no `react-native-webview` modules were included.
|
||||
|
||||
## Approach and renderer choice
|
||||
|
||||
```text
|
||||
Existing Paseo RootApp + Expo Router + state/client/plugin code
|
||||
|
|
||||
React in Hermes
|
||||
|
|
||||
React Native Fabric
|
||||
|
|
||||
Rust GPUI native renderer
|
||||
|
|
||||
Linux GPU window
|
||||
|
||||
Existing Paseo client <-- WebSocket --> separately running Node daemon
|
||||
```
|
||||
|
||||
The separate npm project is deliberate: the selected runtime uses RN 0.87.1,
|
||||
while Paseo's mobile app uses RN 0.86.3. Metro redirects native dependencies to
|
||||
that installation and resolves the rest from the app. This avoids changing the
|
||||
mobile dependency baseline just to try another renderer. It also creates a
|
||||
version-maintenance cost that needs testing before this becomes a supported target.
|
||||
|
||||
The choice was to preserve the existing app's RN component and library contracts:
|
||||
|
||||
| Candidate | Integration model | Consequence for this experiment |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Streetwriters react-native-gpui](https://github.com/streetwriters/react-native-gpui/tree/de11aa265e6fb36c80e8a30315bce6d34a9be7d6) | React Native Fabric, Hermes, and GPUI, with ports of screens, gestures, Reanimated/worklets, safe areas, and SVG | Chosen because it lets the existing RN app reach a real Linux window with targeted adapters. These ports are still experimental. |
|
||||
| [natew/react-native-gpui](https://github.com/natew/react-native-gpui/tree/b2254543357f02fdd983d0d8a584d1d1bd93f5da) | Custom React reconciler with an RN-like component surface; its README targets macOS arm64/Metal | Useful implementation reference. Running this Linux app would also require platform work and compatibility with the RN libraries it imports. |
|
||||
| [GPUIX](https://github.com/remorses/gpuix) | Custom React/Solid renderer, its own JSX primitives, and Node-API native bindings; supports Bun/Node execution | Also native GPUI, with interesting packaging and specialized surfaces. Adopting it would require replacing or adapting Paseo's RN component/library contracts. It remains an alternative worth testing, not a rejected renderer. |
|
||||
| Existing Electron target | RN Web inside Chromium, with the desktop host bridge | Preserves the working desktop feature set, but does not answer the experiment's question of running the app through a native renderer. |
|
||||
|
||||
This is an integration choice, not a performance comparison. There are no
|
||||
controlled CPU, memory, startup, or frame-time results against these alternatives.
|
||||
|
||||
## What belongs in the change
|
||||
|
||||
| Area | Why it is here |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the two local patches. |
|
||||
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
|
||||
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
|
||||
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
|
||||
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
|
||||
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
|
||||
|
||||
Upstream examples, its tester/testing package, website, unused fonts/vector-icon
|
||||
ports, starter navigation dependencies, sample icon, and unused nested Cargo config
|
||||
are omitted. Build outputs, native binaries, downloaded SDKs, logs, screenshots,
|
||||
and the exploratory size-measurement copy stay out of Git.
|
||||
|
||||
The retained runtime still includes its build machinery, platform backends,
|
||||
headers, and tests. Arbitrarily deleting those to reduce line count makes an
|
||||
upstream refresh harder. Vendoring can later be replaced by a pinned dependency
|
||||
once the local patches are upstreamed and distribution is reproducible.
|
||||
|
||||
### Editing the runtime
|
||||
|
||||
Edit `vendor/react-native-gpui`, then run `npm run gpui:install` before rebuilding.
|
||||
npm copies local packages rather than symlinking them; the install command
|
||||
refreshes those copies even when their versions have not changed.
|
||||
|
||||
There are two local renderer changes:
|
||||
|
||||
- `node_view.rs`: a `pointerEvents="box-none"` view must not install its own hit
|
||||
area. Before the patch, Paseo's empty portal overlay intercepted sidebar clicks.
|
||||
- `StatusBar.desktop.js`: desktop windows do not have the mobile OS status bar.
|
||||
|
||||
GPUI remains a pinned Cargo git dependency. Upstream tooling downloads Hermes
|
||||
and React Native prebuilt libraries. The snapshot is not an offline or fully
|
||||
source-built distribution, and release packaging has not been verified.
|
||||
|
||||
## Working slice and limits
|
||||
|
||||
Verified on Linux x86_64: all 24 app routes in the bundle, connection to the dev
|
||||
daemon, the real project/workspace sidebar, opening an existing workspace,
|
||||
existing message rendering, and native composer typing/deletion. The native
|
||||
regression test restores the draft and does not send a message. Rust tests cover
|
||||
SHA-256/random-byte bounds and real SQLite parameter binding/transaction rollback.
|
||||
|
||||
The following are not feature-parity claims:
|
||||
|
||||
| Area | Current state |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Styling | Adapter evaluates existing Unistyles theme/style factories and breakpoints. No Nitro shadow-tree integration. Full theme/resize behavior needs QA. |
|
||||
| Storage/crypto/localization | Native filesystem + SQLite + SHA-256 + OS randomness. Intl ships English locale data and IANA time zones. The Rust service contains Linux-specific OS access. |
|
||||
| Clipboard/files | Text clipboard, native file dialogs, PNG/JPEG picking, and download/export adapters exist. End-to-end dialog/transfer coverage is still missing. |
|
||||
| Voice/camera/push/power inhibition | Unavailable. Requests either report the missing capability or use the app's existing unavailable-capability implementation. |
|
||||
| Image conversion/image clipboard | Unavailable. The picker accepts PNG/JPEG without conversion. |
|
||||
| Mermaid/HTML/document previews | Mermaid and HTML show source. Document previews report unavailable. No embedded browser backend. |
|
||||
| Terminals | The mobile view expects an Expo `PaseoTerminal` component. No GPUI implementation yet. |
|
||||
| Diffs | The mobile surface uses React Native Skia. No GPUI drawing backend yet. |
|
||||
| Plugins | The existing loader evaluates JS and injects host React/RN. No end-to-end plugin verification on GPUI yet. |
|
||||
| Other platforms | The upstream runtime targets macOS/Windows too; this Paseo integration has only been exercised on Linux. |
|
||||
|
||||
The initial stripped development executable was about 68 MiB, with about 10 MiB
|
||||
of Hermes/JSI libraries and a 21 MiB JS bundle, approximately 100 MiB uncompressed.
|
||||
That measurement excludes the daemon, is not an optimized release installer, and
|
||||
does not establish memory usage or a size advantage over another renderer.
|
||||
|
||||
## Experiments to pick up
|
||||
|
||||
Each experiment should preserve the real app and demonstrate a user workflow.
|
||||
|
||||
1. **Native terminal.** Implement a GPUI component for the existing
|
||||
[`PaseoTerminalView`](../modules/paseo-terminal/index.tsx) frame contract.
|
||||
Keep the JS headless xterm emulator, transport, and frame generation initially.
|
||||
Verify resize/replay, keyboard/IME/paste, scrollback/selection, and the existing
|
||||
Unicode corpus. [Terminal rendering](../../../docs/terminal-rendering.md)
|
||||
explains why shaping must preserve xterm's cell columns.
|
||||
2. **Diff surface.** Keep `src/git/diff-document`'s model and review actions;
|
||||
add GPUI text measurement and painting instead of the Skia surface. Test
|
||||
unified/split views, wrapping, scrolling, selection, and inline reviews on
|
||||
large real diffs. The [C++ layout experiment](../modules/paseo-diff-prototype/README.md)
|
||||
is a separate candidate for shared geometry; it is not a finished renderer.
|
||||
3. **Plugins.** Load a small plugin into an isolated daemon, open its real screen
|
||||
or workspace panel, call a plugin RPC, and reload it. Verify subscriptions and
|
||||
cleanup. Track unsupported services explicitly instead of adding fake APIs.
|
||||
4. **Native services and styling.** Exercise dialogs, downloads, clipboard,
|
||||
persistence/restart, theme switching, resizing, and focus. Replace the narrow
|
||||
adapters with complete implementations only where the app needs them.
|
||||
5. **Portability and packaging.** Build on clean Linux, then macOS/Windows. Replace
|
||||
Linux-only randomness/timezone access, check font/shaping and IME behavior, and
|
||||
verify relocatable libraries/assets. Measure a release build and a controlled
|
||||
workflow before claiming a performance or size improvement.
|
||||
6. **Bundled daemon, independently.** First run the unchanged daemon under Bun
|
||||
in isolation. Verify PTY/native dependencies, terminal-worker subprocesses,
|
||||
provider launches, and dynamic plugins. Then compare standalone/AOT packaging
|
||||
and size. This branch neither ports the daemon nor embeds it in the UI process.
|
||||
|
||||
A Rust/GPUI terminal or diff painter can share desktop implementation across the
|
||||
upstream renderer's supported OSes. Reusing it on mobile still requires mobile
|
||||
bindings and graphics integration; choosing Rust or C++ alone does not provide that.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the normal checks from the root. GPUI adapters are excluded from the mobile
|
||||
TypeScript project; its typecheck alone does not validate this target.
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
For the native UI regression, use a daemon with an existing workspace and build
|
||||
with the driver. Run the native app on an isolated display when testing on a
|
||||
machine in use:
|
||||
|
||||
```sh
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run build:gpui -- --features test-driver,visual-capture
|
||||
RN_GPUI_TEST_DRIVER=1 RN_GPUI_TEST_DRIVER_TOKEN=local-test \
|
||||
RN_GPUI_DEV_MODE=0 RN_GPUI_BUNDLE="$PWD/.artifacts/gpui/paseo.bundle" \
|
||||
RN_GPUI_ASSETS_DIR="$PWD/.artifacts/gpui/assets/assets" \
|
||||
xvfb-run -a -s '-screen 0 2560x1600x24' \
|
||||
packages/app/gpui/desktop/target/debug/react-native-gpui-paseo-gpui
|
||||
```
|
||||
|
||||
Use the printed `RN_GPUI_TEST_DRIVER_PORT` in another shell:
|
||||
|
||||
```sh
|
||||
PASEO_GPUI_TEST_URL=ws://127.0.0.1:PORT PASEO_GPUI_TEST_TOKEN=local-test \
|
||||
npx vitest run packages/app/gpui/native.test.ts --bail=1
|
||||
```
|
||||
|
||||
Without a driver URL the test is skipped. Put diagnostic logs/captures in
|
||||
`.artifacts/gpui/`; do not commit them or publish private workspace contents as QA.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { promises as fs, paths } from "@react-native-gpui/fs";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
|
||||
let writes = Promise.resolve();
|
||||
const directory = () => `${paths.appData}/paseo-gpui/storage`;
|
||||
const filename = (key) => `${directory()}/${Desktop.sha256(key)}.json`;
|
||||
async function read(key) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filename(key), "utf8")).value;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function mutate(operation) {
|
||||
const result = writes.then(operation);
|
||||
writes = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
async function write(key, value) {
|
||||
await fs.mkdir(directory(), { recursive: true });
|
||||
const target = filename(key);
|
||||
await fs.writeFile(`${target}.tmp`, JSON.stringify({ key, value }), "utf8");
|
||||
await fs.rename(`${target}.tmp`, target);
|
||||
}
|
||||
async function keys() {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory());
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
return Promise.all(
|
||||
entries
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => JSON.parse(await fs.readFile(`${directory()}/${name}`, "utf8")).key),
|
||||
);
|
||||
}
|
||||
const storage = {
|
||||
async getItem(key) {
|
||||
await writes;
|
||||
return read(key);
|
||||
},
|
||||
setItem(key, value) {
|
||||
return mutate(() => write(key, value));
|
||||
},
|
||||
removeItem(key) {
|
||||
return mutate(() => fs.rm(filename(key), { force: true }));
|
||||
},
|
||||
async getAllKeys() {
|
||||
await writes;
|
||||
return keys();
|
||||
},
|
||||
async multiGet(items) {
|
||||
await writes;
|
||||
return Promise.all(items.map(async (key) => [key, await read(key)]));
|
||||
},
|
||||
multiSet(items) {
|
||||
return mutate(async () => {
|
||||
for (const [key, value] of items) await write(key, value);
|
||||
});
|
||||
},
|
||||
multiRemove(items) {
|
||||
return mutate(async () => {
|
||||
for (const key of items) await fs.rm(filename(key), { force: true });
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
return mutate(() => fs.rm(directory(), { recursive: true, force: true }));
|
||||
},
|
||||
};
|
||||
export default storage;
|
||||
@@ -0,0 +1,21 @@
|
||||
// Preserve the voice model's lifecycle without loading mobile-only Expo audio
|
||||
// during app startup. Requests fail explicitly until a Linux audio backend exists.
|
||||
export function createAudioEngine() {
|
||||
const unavailable = async () => {
|
||||
throw new Error("Voice capture and playback are not available in the GPUI build yet.");
|
||||
};
|
||||
return {
|
||||
initialize: unavailable,
|
||||
startCapture: unavailable,
|
||||
play: unavailable,
|
||||
async destroy() {},
|
||||
async stopCapture() {},
|
||||
stop() {},
|
||||
clearQueue() {},
|
||||
isPlaying: () => false,
|
||||
isMuted: () => false,
|
||||
toggleMute() {
|
||||
throw new Error("Voice capture is not available in the GPUI build yet.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TurboModuleRegistry } from "react-native";
|
||||
const clipboard = TurboModuleRegistry.getEnforcing("Clipboard");
|
||||
export const getStringAsync = () => clipboard.getString();
|
||||
export async function setStringAsync(value) {
|
||||
clipboard.setString(value);
|
||||
return true;
|
||||
}
|
||||
export async function hasStringAsync() {
|
||||
return (await getStringAsync()).length > 0;
|
||||
}
|
||||
export async function getImageAsync() {
|
||||
throw new Error("Image clipboard is not implemented for GPUI yet");
|
||||
}
|
||||
export async function setImageAsync() {
|
||||
throw new Error("Image clipboard is not implemented for GPUI yet");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function getRandomValues(array) {
|
||||
if (
|
||||
!ArrayBuffer.isView(array) ||
|
||||
array instanceof DataView ||
|
||||
array instanceof Float32Array ||
|
||||
array instanceof Float64Array
|
||||
) {
|
||||
throw new TypeError("getRandomValues requires an integer typed array");
|
||||
}
|
||||
if (array.byteLength > 65536) throw new RangeError("Random request exceeds 65536 bytes");
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(
|
||||
Desktop.randomBytes(array.byteLength),
|
||||
);
|
||||
return array;
|
||||
}
|
||||
export function randomUUID() {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
export const CryptoDigestAlgorithm = { SHA256: "SHA-256" };
|
||||
export async function digestStringAsync(algorithm, text) {
|
||||
if (algorithm !== "SHA-256") throw new Error(`Unsupported digest algorithm: ${algorithm}`);
|
||||
return Desktop.sha256(text);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
export async function getDocumentAsync(options = {}) {
|
||||
const paths = await dialog.open({ multiple: options.multiple ?? false });
|
||||
return {
|
||||
canceled: paths.length === 0,
|
||||
assets: await Promise.all(
|
||||
paths.map(async (path) => ({
|
||||
uri: `file://${path}`,
|
||||
name: path.split("/").at(-1),
|
||||
size: (await promises.stat(path)).size,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
|
||||
|
||||
export function FileHtmlPreview({ html, testID }) {
|
||||
return (
|
||||
<ScrollView testID={testID}>
|
||||
<Text>HTML rendering is not available in GPUI. Showing source.</Text>
|
||||
<HighlightedCodeBlock code={html} language="html" />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
export function DocumentFrame({ testID }) {
|
||||
return (
|
||||
<View testID={testID}>
|
||||
<Text>Document preview is not available in the GPUI build yet.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
EventEmitter,
|
||||
NativeModule,
|
||||
SharedObject,
|
||||
SharedRef,
|
||||
} from "expo-modules-core/src/polyfill/CoreModule";
|
||||
export function ensureNativeModulesAreInstalled() {
|
||||
if (globalThis.expo) return;
|
||||
globalThis.expo = { EventEmitter, NativeModule, SharedObject, SharedRef, modules: {} };
|
||||
}
|
||||
ensureNativeModulesAreInstalled();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Linking } from "react-native";
|
||||
import { NativeModule } from "expo-modules-core/src/polyfill/CoreModule";
|
||||
|
||||
class DesktopLinking extends NativeModule {
|
||||
initialURL = null;
|
||||
subscription;
|
||||
constructor() {
|
||||
super();
|
||||
Linking.getInitialURL()
|
||||
.then((url) => {
|
||||
this.initialURL = url;
|
||||
if (url) this.emit("onURLReceived", { url });
|
||||
return url;
|
||||
})
|
||||
.catch((error) => console.error("[GPUI linking]", error));
|
||||
}
|
||||
getLinkingURL = () => this.initialURL;
|
||||
clearInitialURL() {
|
||||
this.initialURL = null;
|
||||
}
|
||||
startObserving() {
|
||||
this.subscription = Linking.addEventListener("url", (event) =>
|
||||
this.emit("onURLReceived", event),
|
||||
);
|
||||
}
|
||||
stopObserving() {
|
||||
this.subscription?.remove();
|
||||
}
|
||||
}
|
||||
export default new DesktopLinking();
|
||||
@@ -0,0 +1,129 @@
|
||||
import fs, { promises, paths } from "@react-native-gpui/fs";
|
||||
import { Buffer } from "buffer";
|
||||
const pathOf = (value) => String(value?.uri ?? value).replace(/^file:\/\//, "");
|
||||
const join = (parts) =>
|
||||
parts
|
||||
.map(pathOf)
|
||||
.join("/")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
export const cacheDirectory = `file://${paths.appCache}/paseo-gpui/`;
|
||||
export const documentDirectory = `file://${paths.appData}/paseo-gpui/`;
|
||||
export const EncodingType = { Base64: "base64", UTF8: "utf8" };
|
||||
export const Paths = { cache: cacheDirectory, document: documentDirectory };
|
||||
export async function getInfoAsync(uri) {
|
||||
try {
|
||||
const stat = await promises.stat(pathOf(uri));
|
||||
return { exists: true, isDirectory: stat.isDirectory(), size: stat.size, uri };
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { exists: false, uri };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const makeDirectoryAsync = (uri, options = {}) =>
|
||||
promises.mkdir(pathOf(uri), { recursive: options.intermediates });
|
||||
export const copyAsync = ({ from, to }) => promises.copyFile(pathOf(from), pathOf(to));
|
||||
export const moveAsync = ({ from, to }) => promises.rename(pathOf(from), pathOf(to));
|
||||
export const readAsStringAsync = (uri, options = {}) =>
|
||||
promises.readFile(pathOf(uri), options.encoding ?? "utf8");
|
||||
export const writeAsStringAsync = (uri, contents, options = {}) =>
|
||||
promises.writeFile(pathOf(uri), contents, options.encoding ?? "utf8");
|
||||
export const deleteAsync = (uri, options = {}) =>
|
||||
promises.rm(pathOf(uri), { recursive: true, force: options.idempotent });
|
||||
export const readDirectoryAsync = (uri) => promises.readdir(pathOf(uri));
|
||||
export class File {
|
||||
constructor(...parts) {
|
||||
this.uri = `file://${join(parts)}`;
|
||||
}
|
||||
get name() {
|
||||
return pathOf(this).split("/").at(-1);
|
||||
}
|
||||
get exists() {
|
||||
return fs.existsSync(pathOf(this));
|
||||
}
|
||||
get size() {
|
||||
return fs.statSync(pathOf(this)).size;
|
||||
}
|
||||
create({ overwrite = false, intermediates = false } = {}) {
|
||||
if (intermediates)
|
||||
fs.mkdirSync(pathOf(this).split("/").slice(0, -1).join("/"), { recursive: true });
|
||||
fs.writeFileSync(pathOf(this), "", { flag: overwrite ? "w" : "wx" });
|
||||
}
|
||||
write(value) {
|
||||
fs.writeFileSync(pathOf(this), value);
|
||||
}
|
||||
delete() {
|
||||
fs.rmSync(pathOf(this));
|
||||
}
|
||||
async bytes() {
|
||||
return promises.readFile(pathOf(this));
|
||||
}
|
||||
async arrayBuffer() {
|
||||
const bytes = await this.bytes();
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
async base64() {
|
||||
return Buffer.from(await this.bytes()).toString("base64");
|
||||
}
|
||||
async text() {
|
||||
return promises.readFile(pathOf(this), "utf8");
|
||||
}
|
||||
move(destination) {
|
||||
fs.renameSync(pathOf(this), pathOf(destination));
|
||||
this.uri = destination.uri;
|
||||
}
|
||||
copy(destination) {
|
||||
fs.copyFileSync(pathOf(this), pathOf(destination));
|
||||
}
|
||||
static async downloadFileAsync(url, destination) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
const file = destination instanceof File ? destination : new File(destination);
|
||||
await promises.writeFile(pathOf(file), new Uint8Array(await response.arrayBuffer()));
|
||||
return file;
|
||||
}
|
||||
}
|
||||
export class Directory {
|
||||
constructor(...parts) {
|
||||
this.uri = `file://${join(parts)}`;
|
||||
}
|
||||
get exists() {
|
||||
return fs.existsSync(pathOf(this));
|
||||
}
|
||||
create({ intermediates = false } = {}) {
|
||||
fs.mkdirSync(pathOf(this), { recursive: intermediates });
|
||||
}
|
||||
delete() {
|
||||
fs.rmSync(pathOf(this), { recursive: true });
|
||||
}
|
||||
list() {
|
||||
return fs
|
||||
.readdirSync(pathOf(this), { withFileTypes: true })
|
||||
.map((entry) =>
|
||||
entry.isDirectory() ? new Directory(this, entry.name) : new File(this, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDownloadResumable(url, uri, options = {}, onProgress) {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
async downloadAsync() {
|
||||
const response = await fetch(url, { headers: options.headers, signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const target = pathOf(uri);
|
||||
await promises.mkdir(target.split("/").slice(0, -1).join("/"), { recursive: true });
|
||||
await promises.writeFile(`${target}.part`, bytes);
|
||||
await promises.rename(`${target}.part`, target);
|
||||
onProgress?.({ totalBytesWritten: bytes.length, totalBytesExpectedToWrite: bytes.length });
|
||||
return {
|
||||
uri,
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
},
|
||||
async cancelAsync() {
|
||||
controller.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// The GPUI picker accepts JPEG/PNG directly. Other formats require a future
|
||||
// native decoder; never claim conversion succeeded for an unsupported format.
|
||||
export const SaveFormat = { PNG: "png", JPEG: "jpeg", WEBP: "webp" };
|
||||
export const ImageManipulator = {
|
||||
manipulate() {
|
||||
throw new Error("Image conversion is not available in the GPUI build; select a PNG or JPEG.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
// Desktop access is granted for each selection by the system file dialog.
|
||||
export const PermissionStatus = {
|
||||
GRANTED: "granted",
|
||||
DENIED: "denied",
|
||||
UNDETERMINED: "undetermined",
|
||||
};
|
||||
const permission = { status: "granted", granted: true, canAskAgain: true, expires: "never" };
|
||||
export const requestMediaLibraryPermissionsAsync = async () => permission;
|
||||
export const useMediaLibraryPermissions = () => [permission, requestMediaLibraryPermissionsAsync];
|
||||
export const getPendingResultAsync = async () => null;
|
||||
export async function launchImageLibraryAsync(options = {}) {
|
||||
const paths = await dialog.open({
|
||||
multiple: options.allowsMultipleSelection,
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg"] }],
|
||||
});
|
||||
return {
|
||||
canceled: !paths.length,
|
||||
assets: await Promise.all(
|
||||
paths.map(async (path) => ({
|
||||
uri: `file://${path}`,
|
||||
fileName: path.split("/").at(-1),
|
||||
fileSize: (await promises.stat(path)).size,
|
||||
mimeType: /\.png$/i.test(path) ? "image/png" : "image/jpeg",
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* oxlint-disable import/no-unassigned-import -- These packages install Intl and locale data by side effect. */
|
||||
// Hermes' Linux build omits Intl; install real locale algorithms before app code.
|
||||
import "@formatjs/intl-getcanonicallocales/polyfill-force.js";
|
||||
import "@formatjs/intl-locale/polyfill-force.js";
|
||||
import "@formatjs/intl-pluralrules/polyfill-force.js";
|
||||
import "@formatjs/intl-pluralrules/locale-data/en.js";
|
||||
import "@formatjs/intl-numberformat/polyfill-force.js";
|
||||
import "@formatjs/intl-numberformat/locale-data/en.js";
|
||||
import "@formatjs/intl-datetimeformat/polyfill-force.js";
|
||||
import "@formatjs/intl-datetimeformat/locale-data/en.js";
|
||||
import "@formatjs/intl-datetimeformat/add-all-tz.js";
|
||||
import "@formatjs/intl-displaynames/polyfill-force.js";
|
||||
import "@formatjs/intl-displaynames/locale-data/en.js";
|
||||
import "@formatjs/intl-segmenter/polyfill-force.js";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
Intl.DateTimeFormat.__setDefaultTimeZone(Desktop.timezone());
|
||||
@@ -0,0 +1,6 @@
|
||||
// No desktop power-inhibition lease is acquired by this backend yet.
|
||||
export const isAvailableAsync = async () => false;
|
||||
export async function activateKeepAwakeAsync() {
|
||||
throw new Error("Power inhibition is not available in the GPUI build.");
|
||||
}
|
||||
export async function deactivateKeepAwake() {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from "react";
|
||||
import { Keyboard, ScrollView, useAnimatedValue } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
// A GPUI desktop window has no software-keyboard inset. RN still owns focus
|
||||
// and physical keyboard events; the mobile layout API observes its events.
|
||||
export function KeyboardProvider({ children }) {
|
||||
return children;
|
||||
}
|
||||
export const KeyboardAwareScrollView = ScrollView;
|
||||
export const KeyboardController = {
|
||||
isVisible: () => Keyboard.isVisible(),
|
||||
dismiss: () => Keyboard.dismiss(),
|
||||
};
|
||||
export const KeyboardEvents = { addListener: (...args) => Keyboard.addListener(...args) };
|
||||
export function useReanimatedKeyboardAnimation() {
|
||||
const height = useSharedValue(0);
|
||||
const progress = useSharedValue(0);
|
||||
useEffect(() => {
|
||||
const shown = Keyboard.addListener("keyboardDidShow", (event) => {
|
||||
height.value = -event.endCoordinates.height;
|
||||
progress.value = 1;
|
||||
});
|
||||
const hidden = Keyboard.addListener("keyboardDidHide", () => {
|
||||
height.value = 0;
|
||||
progress.value = 0;
|
||||
});
|
||||
return () => {
|
||||
shown.remove();
|
||||
hidden.remove();
|
||||
};
|
||||
}, [height, progress]);
|
||||
return { height, progress };
|
||||
}
|
||||
export function useKeyboardAnimation() {
|
||||
const height = useAnimatedValue(0);
|
||||
const progress = useAnimatedValue(0);
|
||||
return { height, progress };
|
||||
}
|
||||
export function useGenericKeyboardHandler(handlers) {
|
||||
useEffect(() => {
|
||||
const subscriptions = [
|
||||
Keyboard.addListener("keyboardDidShow", (event) => {
|
||||
const value = {
|
||||
height: event.endCoordinates.height,
|
||||
progress: 1,
|
||||
duration: event.duration,
|
||||
};
|
||||
handlers.onStart?.(value);
|
||||
handlers.onEnd?.(value);
|
||||
}),
|
||||
Keyboard.addListener("keyboardDidHide", () => {
|
||||
const value = { height: 0, progress: 0, duration: 0 };
|
||||
handlers.onStart?.(value);
|
||||
handlers.onEnd?.(value);
|
||||
}),
|
||||
];
|
||||
return () => subscriptions.forEach((subscription) => subscription.remove());
|
||||
}, [handlers]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function getLocales() {
|
||||
const languageTag = Desktop.locale().split(".")[0].replaceAll("_", "-");
|
||||
return [{ languageTag, languageCode: languageTag.split("-")[0] }];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
|
||||
export function MermaidFenceHost(props) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={props.inheritedStyles}>
|
||||
Diagram rendering is not available in the GPUI build yet.
|
||||
</Text>
|
||||
<HighlightedCodeBlock {...props} language="mermaid" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Buffer } from "buffer";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function randomBytes(length) {
|
||||
return Buffer.from(Desktop.randomBytes(length));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// GPUI's native TextInput owns text paste, selection, and IME. The mobile
|
||||
// PasteInput module's image-paste extension is not available on this target.
|
||||
export { TextInput as default } from "react-native";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
// Linux exports a downloaded file through the native Save dialog.
|
||||
export const isAvailableAsync = async () => true;
|
||||
export async function shareAsync(uri, options = {}) {
|
||||
const source = uri.replace(/^file:\/\//, "");
|
||||
const destination = await dialog.save({
|
||||
title: options.dialogTitle ?? "Save file",
|
||||
defaultName: source.split("/").at(-1),
|
||||
});
|
||||
if (destination) await promises.copyFile(source, destination);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
let pending = Promise.resolve();
|
||||
function enqueue(operation) {
|
||||
const result = pending.then(operation);
|
||||
pending = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
const connection = {
|
||||
async execAsync(sql) {
|
||||
Desktop.sqlite(sql, "batch");
|
||||
},
|
||||
async runAsync(sql, params = []) {
|
||||
Desktop.sqlite(sql, JSON.stringify(params));
|
||||
},
|
||||
async getAllAsync(sql, params = []) {
|
||||
return JSON.parse(Desktop.sqlite(sql, JSON.stringify(params)));
|
||||
},
|
||||
};
|
||||
export async function openDatabaseAsync(name) {
|
||||
if (name !== "paseo-replica-row-store.db") throw new Error(`Unknown GPUI database: ${name}`);
|
||||
return {
|
||||
execAsync: (...args) => enqueue(() => connection.execAsync(...args)),
|
||||
runAsync: (...args) => enqueue(() => connection.runAsync(...args)),
|
||||
getAllAsync: (...args) => enqueue(() => connection.getAllAsync(...args)),
|
||||
withExclusiveTransactionAsync: (operation) =>
|
||||
enqueue(async () => {
|
||||
await connection.execAsync("BEGIN IMMEDIATE");
|
||||
try {
|
||||
await operation(connection);
|
||||
await connection.execAsync("COMMIT");
|
||||
} catch (error) {
|
||||
await connection.execAsync("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as RN from "react-native";
|
||||
import { createUnistylesElement } from "./unistyles";
|
||||
export const View = createUnistylesElement(RN.View);
|
||||
export const Text = createUnistylesElement(RN.Text);
|
||||
export const ScrollView = createUnistylesElement(RN.ScrollView);
|
||||
export const TextInput = createUnistylesElement(RN.TextInput);
|
||||
export const Pressable = createUnistylesElement(RN.Pressable);
|
||||
export const Image = createUnistylesElement(RN.Image);
|
||||
export const ImageBackground = createUnistylesElement(RN.ImageBackground);
|
||||
export const FlatList = createUnistylesElement(RN.FlatList);
|
||||
export const SectionList = createUnistylesElement(RN.SectionList);
|
||||
export const TouchableOpacity = createUnistylesElement(RN.TouchableOpacity);
|
||||
export const TouchableHighlight = createUnistylesElement(RN.TouchableHighlight);
|
||||
export const TouchableWithoutFeedback = createUnistylesElement(RN.TouchableWithoutFeedback);
|
||||
export const ActivityIndicator = createUnistylesElement(RN.ActivityIndicator);
|
||||
export const KeyboardAvoidingView = createUnistylesElement(RN.KeyboardAvoidingView);
|
||||
export const SafeAreaView = createUnistylesElement(RN.SafeAreaView);
|
||||
export const Switch = createUnistylesElement(RN.Switch);
|
||||
export const StatusBar = RN.StatusBar;
|
||||
export const Animated = {
|
||||
...RN.Animated,
|
||||
View: createUnistylesElement(RN.Animated.View),
|
||||
Text: createUnistylesElement(RN.Animated.Text),
|
||||
ScrollView: createUnistylesElement(RN.Animated.ScrollView),
|
||||
Image: createUnistylesElement(RN.Animated.Image),
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { forwardRef, useSyncExternalStore } from "react";
|
||||
import * as Native from "react-native";
|
||||
|
||||
// GPUI has Fabric but no Nitro/Unistyles shadow-tree bindings. Keep the app's
|
||||
// style factories and theme API; this backend updates subscribed React views.
|
||||
const listeners = new Set();
|
||||
const resolveStyle = Symbol("paseo.gpui.style");
|
||||
let revision = 0;
|
||||
let themes = {};
|
||||
let themeName = "dark";
|
||||
let adaptive = true;
|
||||
let breakpoints = {};
|
||||
const emit = () => {
|
||||
revision += 1;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const subscribe = (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
const snapshot = () => revision;
|
||||
const theme = () => themes[themeName];
|
||||
const runtime = () => ({
|
||||
themeName,
|
||||
colorScheme: Native.Appearance.getColorScheme() ?? "light",
|
||||
screen: Native.Dimensions.get("window"),
|
||||
insets: { top: 0, bottom: 0, left: 0, right: 0, ime: 0 },
|
||||
pixelRatio: Native.PixelRatio.get(),
|
||||
fontScale: Native.PixelRatio.getFontScale(),
|
||||
breakpoint: Object.keys(breakpoints).findLast(
|
||||
(key) => breakpoints[key] <= Native.Dimensions.get("window").width,
|
||||
),
|
||||
});
|
||||
Native.Dimensions.addEventListener("change", emit);
|
||||
Native.Appearance.addChangeListener(({ colorScheme }) => {
|
||||
if (adaptive && themes[colorScheme]) themeName = colorScheme;
|
||||
emit();
|
||||
});
|
||||
function responsive(value) {
|
||||
if (Array.isArray(value)) return value.map(responsive);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length && keys.every((key) => key in breakpoints)) {
|
||||
const width = Native.Dimensions.get("window").width;
|
||||
const key = keys
|
||||
.sort((a, b) => breakpoints[a] - breakpoints[b])
|
||||
.findLast((candidate) => breakpoints[candidate] <= width);
|
||||
return key === undefined ? undefined : responsive(value[key]);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, responsive(item)]));
|
||||
}
|
||||
function materialize(style) {
|
||||
if (Array.isArray(style)) return style.map(materialize);
|
||||
if (style && typeof style === "object" && style[resolveStyle]) return style[resolveStyle]();
|
||||
return style;
|
||||
}
|
||||
function styledProps(props) {
|
||||
const result = { ...props };
|
||||
for (const key of ["style", "contentContainerStyle"]) {
|
||||
if (typeof props[key] === "function")
|
||||
result[key] = (...args) => materialize(props[key](...args));
|
||||
else if (props[key]) result[key] = materialize(props[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export const UnistylesRuntime = {
|
||||
get themeName() {
|
||||
return themeName;
|
||||
},
|
||||
get breakpoint() {
|
||||
return runtime().breakpoint;
|
||||
},
|
||||
get colorScheme() {
|
||||
return runtime().colorScheme;
|
||||
},
|
||||
getTheme: theme,
|
||||
setTheme(name) {
|
||||
if (!themes[name]) throw new Error(`Unknown GPUI theme: ${name}`);
|
||||
themeName = name;
|
||||
emit();
|
||||
},
|
||||
updateTheme(name, update) {
|
||||
themes[name] = update(themes[name]);
|
||||
emit();
|
||||
},
|
||||
setAdaptiveThemes(value) {
|
||||
adaptive = value;
|
||||
if (value && themes[runtime().colorScheme]) themeName = runtime().colorScheme;
|
||||
emit();
|
||||
},
|
||||
};
|
||||
export const StyleSheet = {
|
||||
...Native.StyleSheet,
|
||||
configure(config) {
|
||||
themes = config.themes;
|
||||
breakpoints = config.breakpoints;
|
||||
adaptive = config.settings?.adaptiveThemes ?? false;
|
||||
themeName =
|
||||
config.settings?.initialTheme ?? (adaptive ? runtime().colorScheme : Object.keys(themes)[0]);
|
||||
emit();
|
||||
},
|
||||
create(factory) {
|
||||
const evaluate = () => (typeof factory === "function" ? factory(theme(), runtime()) : factory);
|
||||
const initial = evaluate();
|
||||
const sheet = {};
|
||||
for (const key of Object.keys(initial)) {
|
||||
const wrap = (args) => {
|
||||
const resolve = () => {
|
||||
const value = evaluate()[key];
|
||||
return responsive(typeof value === "function" ? value(...args) : value);
|
||||
};
|
||||
return Object.defineProperty({ ...resolve() }, resolveStyle, { value: resolve });
|
||||
};
|
||||
sheet[key] = typeof initial[key] === "function" ? (...args) => wrap(args) : wrap([]);
|
||||
}
|
||||
return sheet;
|
||||
},
|
||||
};
|
||||
// Existing consumers retain this API; new app code should use style factories.
|
||||
export function useUnistyles() {
|
||||
useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
return { theme: theme(), rt: runtime() };
|
||||
}
|
||||
export function withUnistyles(Component, mappings) {
|
||||
const Wrapped = forwardRef(function GpuiThemed(props, ref) {
|
||||
useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
const { uniProps, ...rest } = props;
|
||||
return React.createElement(Component, {
|
||||
...styledProps({
|
||||
...mappings?.(theme(), runtime()),
|
||||
...uniProps?.(theme(), runtime()),
|
||||
...rest,
|
||||
}),
|
||||
ref,
|
||||
});
|
||||
});
|
||||
return Wrapped;
|
||||
}
|
||||
export const createUnistylesElement = withUnistyles;
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "Paseo", "displayName": "Paseo GPUI" }
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [[require.resolve("babel-preset-expo"), { unstable_transformImportMeta: true }]],
|
||||
plugins: [[require.resolve("react-native-unistyles/plugin"), { root: "../src" }]],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
.cache/
|
||||
autolink/
|
||||
target/
|
||||
Generated
+8151
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
[package]
|
||||
name = "react-native-gpui-paseo-gpui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Set CARGO_PROFILE_DEV_DEBUG=full to opt into full debug information.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
split-debuginfo = "packed"
|
||||
|
||||
# Dependencies are optimized even in a dev build: GPUI rebuilds and paints the
|
||||
# element tree on every frame, and they are only compiled once.
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
opt-level = 3
|
||||
|
||||
# The C++ bridge picks its CMake build type from this crate's opt-level, and a
|
||||
# Release build of React Native has no dev server, inspector or Fast Refresh.
|
||||
[profile.dev.package.rn-gpui-sys]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
debug = "line-tables-only"
|
||||
# Cargo strips inside the linker, before dsymutil, so package-desktop writes the
|
||||
# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
|
||||
strip = "none"
|
||||
split-debuginfo = "packed"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
[build-dependencies]
|
||||
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
visual-capture = ["react-native-gpui/visual-capture"]
|
||||
test-driver = ["react-native-gpui/test-driver"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
|
||||
rn-gpui-autolink = { path = "autolink" }
|
||||
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
|
||||
|
||||
[patch.crates-io]
|
||||
# Managed by @react-native-gpui/cli autolink; do not edit between these markers.
|
||||
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
|
||||
rn-gpui-sys = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-sys" }
|
||||
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
|
||||
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/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,172 @@
|
||||
fn main() {
|
||||
// Forwards the engine's Node-API exports out of this executable, which is
|
||||
// what lets a stock `.node` addon resolve them on Windows.
|
||||
//
|
||||
// A DLL records every import as a name *plus the file it comes from*, and a
|
||||
// node-gyp addon records "from `node.exe`" — Windows has no global symbol
|
||||
// scope for it to fall back on. Node's own answer is that the host process
|
||||
// exports the Node-API functions, so that is what this does: the executable
|
||||
// re-exports each of the VM's `napi_*`/`node_api_*` names, forwarded to
|
||||
// `hermesvm.dll`. Addons that use node-gyp's delay-load hook (the default)
|
||||
// then reach this process instead of looking for `node.exe` on disk.
|
||||
//
|
||||
// It is a no-op on macOS and Linux, where an addon's imports resolve against
|
||||
// whatever is already loaded and need no forwarding. Declare it once, before
|
||||
// the module list; it does not depend on anything else in this file.
|
||||
rn_gpui_module_build::forward_node_api_exports();
|
||||
|
||||
// App-local Rust TurboModules: list one entry per `src/Native*.ts` spec,
|
||||
// for example `&["src/NativeGreeting.ts"]`. Their glue is generated into
|
||||
// this crate's OUT_DIR, and main.rs registers the modules.
|
||||
rn_gpui_module_build::generate(&["src/NativePaseoDesktop.ts"]);
|
||||
|
||||
let desktop_root = std::path::PathBuf::from(
|
||||
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"),
|
||||
);
|
||||
// `app.json` names the app; `desktop/desktop.json` carries the identifier
|
||||
// and everything else the packaging step needs. The loader the CLI uses is
|
||||
// JavaScript, so this reads the same file directly and applies the two
|
||||
// defaults it would apply.
|
||||
let project_root = desktop_root
|
||||
.parent()
|
||||
.expect("desktop folder has a React Native project root");
|
||||
let app_json_path = project_root.join("app.json");
|
||||
println!("cargo:rerun-if-changed={}", app_json_path.display());
|
||||
let app_json: serde_json::Value = serde_json::from_slice(
|
||||
&std::fs::read(&app_json_path)
|
||||
.unwrap_or_else(|error| panic!("read {}: {error}", app_json_path.display())),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse {}: {error}", app_json_path.display()));
|
||||
let app_name = app_json["name"]
|
||||
.as_str()
|
||||
.filter(|name| !name.is_empty())
|
||||
.expect("app.json must define a nonempty name");
|
||||
if app_json["desktop"]
|
||||
.as_object()
|
||||
.is_some_and(|desktop| !desktop.is_empty())
|
||||
{
|
||||
panic!("move `app.json` desktop.* into `desktop/desktop.json` (see the README)");
|
||||
}
|
||||
|
||||
let desktop_json_path = project_root.join("desktop/desktop.json");
|
||||
println!("cargo:rerun-if-changed={}", desktop_json_path.display());
|
||||
let desktop_json: serde_json::Value = std::fs::read(&desktop_json_path)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let app_id = match desktop_json["identifier"].as_str() {
|
||||
Some(identifier) if !identifier.is_empty() => identifier.to_string(),
|
||||
_ => format!(
|
||||
"com.example.{}",
|
||||
app_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
),
|
||||
};
|
||||
println!("cargo:rustc-env=RN_GPUI_APP_NAME={app_name}");
|
||||
println!("cargo:rustc-env=RN_GPUI_APP_ID={app_id}");
|
||||
// Only a debug build names the bundle it was developed against. A release
|
||||
// binary must find the bundle beside its own executable, because the path
|
||||
// baked in here is where this machine happened to build it — and on the
|
||||
// machine the package is installed on, that path does not exist.
|
||||
println!("cargo:rerun-if-env-changed=PROFILE");
|
||||
if std::env::var("PROFILE").as_deref() == Ok("debug") {
|
||||
println!(
|
||||
"cargo:rustc-env=RN_GPUI_DEFAULT_BUNDLE={}",
|
||||
desktop_root.join("build/index.bundle").display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-env-changed=DEP_RN_GPUI_RUNTIME_RPATHS");
|
||||
println!("cargo:rerun-if-env-changed=RN_GPUI_PACKAGING");
|
||||
let Ok(paths) = std::env::var("DEP_RN_GPUI_RUNTIME_RPATHS") else {
|
||||
return;
|
||||
};
|
||||
let dirs = paths.split('|').filter(|path| !path.is_empty());
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
stage_shared_libraries(dirs);
|
||||
return;
|
||||
}
|
||||
// `RN_GPUI_PACKAGING` means this binary is about to be installed somewhere
|
||||
// else, so only the relative rpaths belong in it. Keeping the absolute ones
|
||||
// would let a packaged app load this build tree's libraries in preference to
|
||||
// its own on the machine that built it — which hides exactly the packaging
|
||||
// bugs the packaged run exists to catch.
|
||||
let packaging = std::env::var("RN_GPUI_PACKAGING").is_ok();
|
||||
if !packaging {
|
||||
for path in dirs {
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,{path}");
|
||||
}
|
||||
}
|
||||
emit_relocatable_rpaths(app_name);
|
||||
}
|
||||
|
||||
/// rpaths that survive being installed somewhere else.
|
||||
///
|
||||
/// The absolute paths above let a development build run from this tree. A
|
||||
/// packaged app is copied to another machine, where those paths do not exist,
|
||||
/// so a release build also carries paths relative to the executable: the
|
||||
/// layout the installers produce.
|
||||
fn emit_relocatable_rpaths(app_name: &str) {
|
||||
if std::env::var("PROFILE").as_deref() != Ok("release") {
|
||||
return;
|
||||
}
|
||||
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let mut emit = |rpath: String| println!("cargo:rustc-link-arg=-Wl,-rpath,{rpath}");
|
||||
match os.as_str() {
|
||||
// An app bundle puts the executable in Contents/MacOS and the shipped
|
||||
// libraries in Contents/Frameworks.
|
||||
"macos" => emit("@executable_path/../Frameworks".into()),
|
||||
// Linux installs the executable at `usr/bin` and ships its libraries
|
||||
// under `usr/lib`:
|
||||
// - the deb puts them in `usr/lib/<app>`;
|
||||
// - an AppImage's libraries are flat in `usr/lib`, because that is the
|
||||
// directory AppRun puts on the library path and the one linuxdeploy
|
||||
// resolves dependencies from before the image is assembled.
|
||||
// Both are covered, so one binary works in either layout.
|
||||
"linux" => {
|
||||
emit(format!("$ORIGIN/../lib/{app_name}"));
|
||||
emit("$ORIGIN/../lib".into());
|
||||
emit("$ORIGIN".into());
|
||||
}
|
||||
// Windows has no rpath: the DLLs are copied beside the executable.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows has no rpath: the loader finds a DLL next to the executable or on
|
||||
/// `PATH`, so every directory the runtime reports as holding a shared library
|
||||
/// (the Hermes binary directory in particular) is copied into the profile
|
||||
/// directory that receives this app's executable. Without this the app builds
|
||||
/// and then fails to start with "hermesvm.dll was not found".
|
||||
fn stage_shared_libraries<'a>(dirs: impl Iterator<Item = &'a str>) {
|
||||
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is set"));
|
||||
// <profile>/build/<package>-<hash>/out -> <profile>
|
||||
let Some(profile_dir) = out_dir.ancestors().nth(3) else {
|
||||
return;
|
||||
};
|
||||
for dir in dirs {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let source = entry.path();
|
||||
let is_shared_library = source
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("dll"));
|
||||
if !is_shared_library {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", source.display());
|
||||
std::fs::copy(&source, profile_dir.join(name)).unwrap_or_else(|error| {
|
||||
panic!("copy {} next to the executable: {error}", source.display())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "identifier": "sh.paseo.gpui" }
|
||||
@@ -0,0 +1,83 @@
|
||||
const path = require("node:path");
|
||||
const { makeMetroConfig } = require("@react-native-gpui/core/metro.config");
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
process.env.EXPO_ROUTER_APP_ROOT = path.resolve(projectRoot, "../src/app");
|
||||
const config = makeMetroConfig({ projectRoot });
|
||||
const resolveRequest = config.resolver.resolveRequest;
|
||||
config.watchFolders.push(path.resolve(projectRoot, "../../.."));
|
||||
config.transformer.unstable_allowRequireContext = true;
|
||||
config.resolver.disableHierarchicalLookup = false;
|
||||
config.resolver.blockList = /[/\\]gpui[/\\]desktop[/\\](target|build|autolink)[/\\]/;
|
||||
const nativePackages = [
|
||||
"react",
|
||||
"react-native-reanimated",
|
||||
"react-native-worklets",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-screens",
|
||||
"react-native-safe-area-context",
|
||||
"react-native-svg",
|
||||
];
|
||||
|
||||
const adapters = {
|
||||
"expo-keep-awake": "keep-awake.js",
|
||||
"expo-image-picker": "image-picker.js",
|
||||
"expo-image-manipulator": "image-manipulator.js",
|
||||
"expo-sharing": "sharing.js",
|
||||
"expo-document-picker": "document-picker.js",
|
||||
"@mattermost/react-native-paste-input": "paste-input.js",
|
||||
"@react-native-async-storage/async-storage": "async-storage.js",
|
||||
"expo-localization": "localization.js",
|
||||
"expo-clipboard": "clipboard.js",
|
||||
"expo-file-system": "file-system.js",
|
||||
"expo-file-system/legacy": "file-system.js",
|
||||
"react-native-keyboard-controller": "keyboard.jsx",
|
||||
crypto: "node-crypto.js",
|
||||
"node:crypto": "node-crypto.js",
|
||||
"expo-sqlite": "sqlite.js",
|
||||
"expo-crypto": "crypto.js",
|
||||
"react-native-unistyles": "unistyles.js",
|
||||
};
|
||||
const fileAdapters = {
|
||||
"/audio/audio-engine.native.ts": "audio.js",
|
||||
"/components/markdown/fence/mermaid/host.native.tsx": "mermaid.jsx",
|
||||
"/file-panels/html-preview.tsx": "document-preview.jsx",
|
||||
"/file-panels/document-frame/index.tsx": "document-preview.jsx",
|
||||
};
|
||||
config.resolver.resolveRequest = (context, name, platform) => {
|
||||
if (adapters[name]) name = path.resolve(projectRoot, "adapters", adapters[name]);
|
||||
if (
|
||||
name.endsWith("/ensureNativeModulesAreInstalled") &&
|
||||
context.originModulePath.includes("expo-modules-core")
|
||||
)
|
||||
name = path.resolve(projectRoot, "adapters/expo-install.js");
|
||||
if (name === "./ExpoLinking" && context.originModulePath.includes("expo-linking"))
|
||||
name = path.resolve(projectRoot, "adapters/expo-linking.js");
|
||||
// Linux has no Expo mobile push-token or camera provider; reuse the app's
|
||||
// existing unavailable-capability implementations.
|
||||
if (name === "expo-notifications")
|
||||
name = path.resolve(projectRoot, "../src/fdroid/expo-notifications.ts");
|
||||
if (name === "expo-camera") name = path.resolve(projectRoot, "../src/fdroid/expo-camera.tsx");
|
||||
if (name === "expo-router/_ctx") name = path.resolve(projectRoot, "routes.js");
|
||||
if (name === "qrcode") name = "qrcode/lib/browser.js";
|
||||
if (name.startsWith("react-native-unistyles/components/native/"))
|
||||
name = path.resolve(projectRoot, "adapters/unistyles-components.js");
|
||||
if (name.startsWith("@/")) name = path.resolve(projectRoot, "../src", name.slice(2));
|
||||
if (
|
||||
name.startsWith("react-native/") ||
|
||||
nativePackages.some((pkg) => name === pkg || name.startsWith(`${pkg}/`))
|
||||
) {
|
||||
context = {
|
||||
...context,
|
||||
disableHierarchicalLookup: true,
|
||||
nodeModulesPaths: [path.join(projectRoot, "node_modules")],
|
||||
};
|
||||
}
|
||||
const result = resolveRequest(context, name, platform);
|
||||
const replacement = Object.entries(fileAdapters).find(([suffix]) =>
|
||||
result.filePath?.endsWith(suffix),
|
||||
);
|
||||
if (replacement)
|
||||
return { type: "sourceFile", filePath: path.resolve(projectRoot, "adapters", replacement[1]) };
|
||||
return result;
|
||||
};
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { TurboModuleRegistry, type TurboModule } from "react-native";
|
||||
export interface Spec extends TurboModule {
|
||||
sqlite(sql: string, params: string): string;
|
||||
locale(): string;
|
||||
timezone(): string;
|
||||
sha256(text: string): string;
|
||||
randomBytes(length: number): Array<number>;
|
||||
}
|
||||
export default TurboModuleRegistry.getEnforcing<Spec>("PaseoDesktop");
|
||||
@@ -0,0 +1,29 @@
|
||||
rn_gpui_module::include_specs!();
|
||||
|
||||
mod services;
|
||||
|
||||
fn main() {
|
||||
react_native_gpui::register_module(native_paseo_desktop_module::<services::PaseoDesktop>());
|
||||
rn_gpui_autolink::ensure_linked();
|
||||
// A debug build knows where its own bundle is; a release build does not, and
|
||||
// must not: the path would name the machine that built it. A packaged release
|
||||
// app resolves the bundle beside its executable instead.
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var_os("RN_GPUI_BUNDLE").is_none() {
|
||||
std::env::set_var("RN_GPUI_BUNDLE", env!("RN_GPUI_DEFAULT_BUNDLE"));
|
||||
}
|
||||
let config = react_native_gpui::DesktopAppConfig::from_env_with_defaults(
|
||||
env!("RN_GPUI_APP_NAME"),
|
||||
env!("RN_GPUI_APP_ID"),
|
||||
)
|
||||
.window_title("Paseo GPUI")
|
||||
.window_size(1024.0, 768.0)
|
||||
.dev_menu(true)
|
||||
.configure_window(|options| {
|
||||
// GPUI window options are public: titlebar, resizability, display,
|
||||
// decorations, min size, and other native window behavior.
|
||||
options.is_resizable = true;
|
||||
});
|
||||
|
||||
react_native_gpui::run_with(config);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::{NativePaseoDesktopEvents, NativePaseoDesktopSpec};
|
||||
use rn_gpui_module::ModuleError;
|
||||
use std::io::Read;
|
||||
|
||||
pub struct PaseoDesktop {
|
||||
database: std::sync::Mutex<Option<rusqlite::Connection>>,
|
||||
}
|
||||
impl PaseoDesktop {
|
||||
fn database(
|
||||
&self,
|
||||
) -> Result<std::sync::MutexGuard<'_, Option<rusqlite::Connection>>, ModuleError> {
|
||||
let mut guard = self
|
||||
.database
|
||||
.lock()
|
||||
.map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
if guard.is_none() {
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
std::path::PathBuf::from(std::env::var("HOME").expect("HOME is set"))
|
||||
.join(".local/share")
|
||||
});
|
||||
let dir = base.join("paseo-gpui");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
*guard = Some(
|
||||
rusqlite::Connection::open(dir.join("replica.db"))
|
||||
.map_err(|e| ModuleError::new(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
}
|
||||
impl NativePaseoDesktopSpec for PaseoDesktop {
|
||||
fn new(_events: NativePaseoDesktopEvents) -> Self {
|
||||
Self {
|
||||
database: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
fn sqlite(&self, sql: String, params: String) -> Result<String, ModuleError> {
|
||||
let fail = |e: rusqlite::Error| ModuleError::new(e.to_string());
|
||||
let db = self.database()?;
|
||||
let db = db.as_ref().unwrap();
|
||||
if params == "batch" {
|
||||
db.execute_batch(&sql).map_err(fail)?;
|
||||
return Ok("[]".into());
|
||||
}
|
||||
let values: Vec<serde_json::Value> =
|
||||
serde_json::from_str(¶ms).map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
let values: Vec<rusqlite::types::Value> = values
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
serde_json::Value::Null => rusqlite::types::Value::Null,
|
||||
serde_json::Value::String(v) => rusqlite::types::Value::Text(v),
|
||||
serde_json::Value::Number(v) => v
|
||||
.as_i64()
|
||||
.map(rusqlite::types::Value::Integer)
|
||||
.unwrap_or_else(|| rusqlite::types::Value::Real(v.as_f64().unwrap())),
|
||||
_ => rusqlite::types::Value::Text(v.to_string()),
|
||||
})
|
||||
.collect();
|
||||
let mut stmt = db.prepare(&sql).map_err(fail)?;
|
||||
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
|
||||
let mut rows = stmt
|
||||
.query(rusqlite::params_from_iter(values))
|
||||
.map_err(fail)?;
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(fail)? {
|
||||
let mut object = serde_json::Map::new();
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
let value = match row.get_ref(index).map_err(fail)? {
|
||||
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
|
||||
rusqlite::types::ValueRef::Integer(v) => v.into(),
|
||||
rusqlite::types::ValueRef::Real(v) => serde_json::json!(v),
|
||||
rusqlite::types::ValueRef::Text(v) => {
|
||||
String::from_utf8_lossy(v).into_owned().into()
|
||||
}
|
||||
rusqlite::types::ValueRef::Blob(_) => {
|
||||
return Err(ModuleError::new(
|
||||
"Replica store does not support blob columns",
|
||||
))
|
||||
}
|
||||
};
|
||||
object.insert(name.clone(), value);
|
||||
}
|
||||
results.push(object);
|
||||
}
|
||||
serde_json::to_string(&results).map_err(|e| ModuleError::new(e.to_string()))
|
||||
}
|
||||
fn timezone(&self) -> Result<String, ModuleError> {
|
||||
if let Ok(zone) = std::env::var("TZ") {
|
||||
if !zone.is_empty() {
|
||||
return Ok(zone.trim_start_matches(':').into());
|
||||
}
|
||||
}
|
||||
if let Ok(path) = std::fs::read_link("/etc/localtime") {
|
||||
if let Some((_, zone)) = path.to_string_lossy().split_once("/zoneinfo/") {
|
||||
return Ok(zone.into());
|
||||
}
|
||||
}
|
||||
Ok(std::fs::read_to_string("/etc/timezone")
|
||||
.map(|s| s.trim().into())
|
||||
.unwrap_or_else(|_| "UTC".into()))
|
||||
}
|
||||
fn locale(&self) -> Result<String, ModuleError> {
|
||||
Ok(["LC_ALL", "LC_MESSAGES", "LANG"]
|
||||
.iter()
|
||||
.find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty()))
|
||||
.unwrap_or_else(|| "en_US.UTF-8".into()))
|
||||
}
|
||||
fn sha256(&self, text: String) -> Result<String, ModuleError> {
|
||||
use sha2::{Digest, Sha256};
|
||||
Ok(format!("{:x}", Sha256::digest(text.as_bytes())))
|
||||
}
|
||||
fn random_bytes(&self, length: f64) -> Result<Vec<f64>, ModuleError> {
|
||||
if !length.is_finite() || length < 0.0 || length > 65536.0 || length.fract() != 0.0 {
|
||||
return Err(ModuleError::new("Invalid random byte count"));
|
||||
}
|
||||
let mut bytes = vec![0u8; length as usize];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut source| source.read_exact(&mut bytes))
|
||||
.map_err(|error| ModuleError::new(error.to_string()))?;
|
||||
Ok(bytes.into_iter().map(f64::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn service() -> PaseoDesktop {
|
||||
PaseoDesktop {
|
||||
database: std::sync::Mutex::new(Some(rusqlite::Connection::open_in_memory().unwrap())),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn crypto_matches_sha256_and_bounds_random_requests() {
|
||||
let service = service();
|
||||
assert_eq!(
|
||||
service.sha256("abc".into()).unwrap(),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
assert_eq!(service.random_bytes(32.0).unwrap().len(), 32);
|
||||
assert!(service.random_bytes(65537.0).is_err());
|
||||
assert!(service.random_bytes(-1.0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn replica_transactions_bind_values_and_roll_back() {
|
||||
let service = service();
|
||||
service
|
||||
.sqlite(
|
||||
"CREATE TABLE rows (key TEXT PRIMARY KEY, value TEXT);".into(),
|
||||
"batch".into(),
|
||||
)
|
||||
.unwrap();
|
||||
service
|
||||
.sqlite(
|
||||
"INSERT INTO rows VALUES (?, ?)".into(),
|
||||
r#"["key","quoted ' value"]"#.into(),
|
||||
)
|
||||
.unwrap();
|
||||
service
|
||||
.sqlite(
|
||||
"BEGIN IMMEDIATE; DELETE FROM rows; ROLLBACK;".into(),
|
||||
"batch".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = service
|
||||
.sqlite("SELECT key, value FROM rows".into(), "[]".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
|
||||
serde_json::json!([{"key":"key","value":"quoted ' value"}])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* oxlint-disable import/no-unassigned-import -- Install globals before app modules evaluate. */
|
||||
import "./adapters/intl";
|
||||
import "../src/polyfills/install-early";
|
||||
import "../src/styles/unistyles";
|
||||
import { AppRegistry } from "react-native";
|
||||
import { RootApp } from "../src/root-app";
|
||||
|
||||
// GPUI owns the native surface. Keep Paseo's full router and providers, while
|
||||
// leaving Expo's mobile registration and WebView-based dev overlay out.
|
||||
AppRegistry.registerComponent("Paseo", () => RootApp);
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterAll, beforeAll, expect, test } from "vitest";
|
||||
|
||||
// Runs against the actual Paseo bundle in the compiled GPUI app. Start with
|
||||
// RN_GPUI_TEST_DRIVER=1 and pass its loopback URL and token to this test.
|
||||
const endpoint = process.env.PASEO_GPUI_TEST_URL;
|
||||
const token = process.env.PASEO_GPUI_TEST_TOKEN;
|
||||
let socket: WebSocket;
|
||||
let nextId = 0;
|
||||
interface Node {
|
||||
tag: number;
|
||||
component: string;
|
||||
testID: string;
|
||||
text: string;
|
||||
children?: Node[];
|
||||
}
|
||||
const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
|
||||
function request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`GPUI ${method} timed out`));
|
||||
}, 15_000);
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, token } }));
|
||||
});
|
||||
}
|
||||
function flatten(node: Node): Node[] {
|
||||
return [node, ...(node.children ?? []).flatMap(flatten)];
|
||||
}
|
||||
async function nodes() {
|
||||
return (await request<{ children: Node[] }>("tree")).children.flatMap(flatten);
|
||||
}
|
||||
beforeAll(async () => {
|
||||
if (!endpoint) return;
|
||||
if (!token) throw new Error("PASEO_GPUI_TEST_TOKEN is required");
|
||||
socket = new WebSocket(endpoint);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener("open", () => resolve(), { once: true });
|
||||
socket.addEventListener("error", () => reject(new Error("GPUI driver connection failed")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
const response = JSON.parse(String(event.data));
|
||||
const waiter = pending.get(response.id);
|
||||
if (!waiter) return;
|
||||
pending.delete(response.id);
|
||||
const error = response.error ?? response.result?.error;
|
||||
if (error) waiter.reject(new Error(JSON.stringify(error)));
|
||||
else waiter.resolve(response.result);
|
||||
});
|
||||
});
|
||||
afterAll(() => socket?.close());
|
||||
|
||||
test.skipIf(!endpoint)(
|
||||
"real Paseo portal passes workspace clicks and the native composer edits text",
|
||||
async () => {
|
||||
const workspace = (await nodes()).find((node) =>
|
||||
node.testID.startsWith("sidebar-workspace-row-"),
|
||||
);
|
||||
expect(workspace, "connected daemon must have an existing workspace").toBeDefined();
|
||||
// Regression: the upstream box-none portal hit area intercepted this click.
|
||||
expect(await request("click", { tag: workspace!.tag })).toEqual({ clicked: true });
|
||||
await expect
|
||||
.poll(async () => (await nodes()).some((node) => node.testID === "workspace-header-title"), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => (await nodes()).some((node) => node.component === "TextInput"), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
const input = (await nodes()).find((node) => node.component === "TextInput")!;
|
||||
const original = (await request<{ text: string }>("getProps", { tag: input.tag })).text;
|
||||
try {
|
||||
await request("click", { tag: input.tag });
|
||||
await request("press", { key: "ctrl-a" });
|
||||
await request("press", { key: "backspace" });
|
||||
expect(
|
||||
await request("type", { tag: input.tag, text: "GPUI native input regression" }),
|
||||
).toEqual({ typed: true });
|
||||
await expect
|
||||
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
|
||||
.toBe("GPUI native input regression");
|
||||
} finally {
|
||||
await request("press", { key: "ctrl-a" });
|
||||
await request("press", { key: "backspace" });
|
||||
if (original) await request("type", { tag: input.tag, text: original });
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
Generated
+6661
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "paseo-gpui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@formatjs/intl-datetimeformat": "^7.9.0",
|
||||
"@formatjs/intl-displaynames": "^7.4.0",
|
||||
"@formatjs/intl-getcanonicallocales": "^3.3.0",
|
||||
"@formatjs/intl-locale": "^5.4.1",
|
||||
"@formatjs/intl-numberformat": "^9.5.0",
|
||||
"@formatjs/intl-pluralrules": "^6.4.0",
|
||||
"@formatjs/intl-segmenter": "^12.3.0",
|
||||
"@react-native-gpui/codegen": "file:../../../vendor/react-native-gpui/packages/codegen",
|
||||
"@react-native-gpui/core": "file:../../../vendor/react-native-gpui/packages/core",
|
||||
"@react-native-gpui/fs": "file:../../../vendor/react-native-gpui/modules/fs",
|
||||
"@react-native-gpui/gesture-handler": "file:../../../vendor/react-native-gpui/modules/gesture-handler",
|
||||
"@react-native-gpui/hermes": "file:../../../vendor/react-native-gpui/packages/hermes",
|
||||
"@react-native-gpui/reanimated": "file:../../../vendor/react-native-gpui/modules/reanimated",
|
||||
"@react-native-gpui/safe-area-context": "file:../../../vendor/react-native-gpui/modules/safe-area-context",
|
||||
"@react-native-gpui/screens": "file:../../../vendor/react-native-gpui/modules/screens",
|
||||
"@react-native-gpui/svg": "file:../../../vendor/react-native-gpui/modules/svg",
|
||||
"@react-native-gpui/worklets": "file:../../../vendor/react-native-gpui/modules/worklets",
|
||||
"@react-native/assets-registry": "^0.87.1",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.87.1",
|
||||
"react-native-gesture-handler": "3.3.0",
|
||||
"react-native-reanimated": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/4.7.1-desktop.1/react-native-reanimated-4.7.1.tgz",
|
||||
"react-native-safe-area-context": "5.10.1",
|
||||
"react-native-screens": "4.28.0",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-worklets": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/worklets-0.13.0-desktop.1/react-native-worklets-0.13.0.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "20.2.0",
|
||||
"@react-native-gpui/cli": "file:../../../vendor/react-native-gpui/packages/cli",
|
||||
"@react-native/babel-preset": "0.87.1",
|
||||
"@react-native/metro-config": "0.87.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="@react-native-gpui/core/types" />
|
||||
// Added by react-native-gpui. Do not edit: it is regenerated.
|
||||
@@ -0,0 +1,17 @@
|
||||
const path = require("node:path");
|
||||
module.exports = {
|
||||
// This target only launches desktop. RN CLI 20.2's built-in
|
||||
// run-ios/run-android option lists conflict with the CLI's global verbose
|
||||
// option, and this app has no native mobile projects to launch.
|
||||
commands: ["build-android", "build-ios", "log-android", "log-ios", "run-android", "run-ios"].map(
|
||||
(name) => ({
|
||||
name,
|
||||
description: `${name} is not configured for Paseo GPUI`,
|
||||
func: () => {
|
||||
throw new Error(`${name} is not configured for Paseo GPUI`);
|
||||
},
|
||||
}),
|
||||
),
|
||||
project: { desktop: {} },
|
||||
reactNativePath: path.dirname(require.resolve("react-native/package.json")),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// Use the existing route tree. Expo's transformer normally supplies this root
|
||||
// through a Babel caller field; the RN desktop transformer does not.
|
||||
export const ctx = require.context(
|
||||
"../src/app",
|
||||
true,
|
||||
/^(?:\.\/)(?!(?:(?:(?:.*\+api)|(?:\+(html|native-intent))))\.[tj]sx?$).*\.[tj]sx?$/,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
profile = "minimal"
|
||||
@@ -15,7 +15,7 @@ import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mut
|
||||
import { TaskListRow } from "@/components/task-list-row";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
|
||||
import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -1800,7 +1800,7 @@ function computeShimmerMetrics(input: {
|
||||
// React Native Web only observes a node when onLayout exists at mount. Keep
|
||||
// measuring while idle so a retained badge has dimensions when it starts loading.
|
||||
const shouldMeasureWebShimmer = isWeb;
|
||||
const shouldMeasureNativeShimmer = input.isLoading && isNative;
|
||||
const shouldMeasureNativeShimmer = input.isLoading && isNative && supportsMaskedEffects;
|
||||
const isNativeShimmer =
|
||||
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
|
||||
const webShimmerSpanStartX = input.labelOffsetX;
|
||||
|
||||
@@ -25,6 +25,9 @@ export const isWeb = Platform.OS === "web";
|
||||
/** iOS or Android — the JS runtime is React Native. */
|
||||
export const isNative = Platform.OS !== "web";
|
||||
|
||||
/** Platforms with an alpha-mask compositor for decorative shimmer effects. */
|
||||
export const supportsMaskedEffects = ["web", "ios", "android"].includes(Platform.OS);
|
||||
|
||||
/** Development build/runtime — true in Metro dev bundles, false in production. */
|
||||
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
|
||||
interface StartupSplashScreenProps {
|
||||
@@ -86,7 +86,11 @@ function LogoShimmer() {
|
||||
return <WebLogoShimmer color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
return <NativeLogoShimmer color={theme.colors.foreground} />;
|
||||
return supportsMaskedEffects ? (
|
||||
<NativeLogoShimmer color={theme.colors.foreground} />
|
||||
) : (
|
||||
<PaseoLogo size={LOGO_SIZE} color={theme.colors.foreground} />
|
||||
);
|
||||
}
|
||||
|
||||
function WebLogoShimmer({ color }: { color: string }) {
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts",
|
||||
"react-native-web.d.ts"
|
||||
]
|
||||
],
|
||||
"exclude": ["gpui"]
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ function spawnRelayDevServer(port: number): ChildProcess {
|
||||
wranglerCliPath,
|
||||
"dev",
|
||||
"--local",
|
||||
// Exercise the local Durable Object, independent of production cutover routing.
|
||||
"--var",
|
||||
"PASEO_RELAY_UPSTREAM:",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { cpSync, mkdirSync, readFileSync } from "node:fs";
|
||||
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const project = path.join(root, "packages/app/gpui");
|
||||
const action = process.argv[2] ?? "run";
|
||||
const extra = process.argv.slice(3);
|
||||
const artifacts = path.join(root, ".artifacts/gpui");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function run(command, args, env = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: project,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_RENDERER: "gpui",
|
||||
CARGO_BUILD_JOBS: process.env.CARGO_BUILD_JOBS ?? "4",
|
||||
...env,
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
if (action === "install") {
|
||||
run("npm", ["install", "--workspaces=false"]);
|
||||
// npm copies file: packages. Refresh vendored source even when its version
|
||||
// did not change, so edits to the native renderer reach the next Cargo build.
|
||||
const manifest = JSON.parse(readFileSync(path.join(project, "package.json"), "utf8"));
|
||||
for (const [name, spec] of Object.entries({
|
||||
...manifest.dependencies,
|
||||
...manifest.devDependencies,
|
||||
})) {
|
||||
if (spec.startsWith("file:"))
|
||||
cpSync(path.resolve(project, spec.slice(5)), path.join(project, "node_modules", name), {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
} else if (action === "bundle") {
|
||||
run("node", [
|
||||
"node_modules/react-native/cli.js",
|
||||
"bundle",
|
||||
"--platform",
|
||||
"desktop",
|
||||
"--entry-file",
|
||||
"index.js",
|
||||
"--config",
|
||||
"desktop/metro.config.js",
|
||||
"--bundle-output",
|
||||
path.join(root, ".artifacts/gpui/paseo.bundle"),
|
||||
"--sourcemap-output",
|
||||
path.join(artifacts, "paseo.bundle.map"),
|
||||
"--assets-dest",
|
||||
path.join(artifacts, "assets"),
|
||||
"--max-workers",
|
||||
"2",
|
||||
...extra,
|
||||
]);
|
||||
const map = JSON.parse(readFileSync(path.join(artifacts, "paseo.bundle.map"), "utf8"));
|
||||
const sources = map.sections
|
||||
? map.sections.flatMap((section) => section.map.sources)
|
||||
: map.sources;
|
||||
if (sources.some((source) => source.includes("/react-native-webview/")))
|
||||
throw new Error("GPUI bundle must not include WebView modules");
|
||||
for (const route of [
|
||||
"src/app/_layout.tsx",
|
||||
"src/app/open-project.tsx",
|
||||
"src/app/h/[serverId]/workspace/[workspaceId]/index.tsx",
|
||||
]) {
|
||||
if (!sources.some((source) => source.endsWith(route)))
|
||||
throw new Error(`GPUI bundle is missing Paseo route: ${route}`);
|
||||
}
|
||||
} else if (action === "build" || action === "run") {
|
||||
run(process.execPath, [fileURLToPath(import.meta.url), "bundle", "--dev", "false"]);
|
||||
run("node", ["node_modules/@react-native-gpui/cli/bin/react-native-gpui.js", "autolink"]);
|
||||
run("cargo", [action, "--manifest-path", "desktop/Cargo.toml", ...extra], {
|
||||
RN_GPUI_AUTOLINK_MANIFEST: path.join(project, "desktop/autolink/autolink.json"),
|
||||
RN_GPUI_AUTOLINK_CMAKE: path.join(project, "desktop/autolink/autolink.cmake"),
|
||||
RN_GPUI_DEV_MODE: "0",
|
||||
RN_GPUI_ASSETS_DIR: path.join(artifacts, "assets/assets"),
|
||||
RN_GPUI_BUNDLE: path.join(root, ".artifacts/gpui/paseo.bundle"),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unknown GPUI action: ${action}`);
|
||||
}
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+21
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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" }
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||
+500
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+71
@@ -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;
|
||||
+32
@@ -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');
|
||||
+13
@@ -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}\"")
|
||||
+161
@@ -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")
|
||||
Vendored
+48
@@ -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));
|
||||
}
|
||||
+22
@@ -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
|
||||
+143
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+23
@@ -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}")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user