mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
feat(app): add an experimental native desktop target
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user