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 | ||
|
|
e02fa9a5f8 | ||
|
|
b117e3fb0f | ||
|
|
2ac288f4d0 | ||
|
|
1008e9fffc | ||
|
|
74613e4bc3 | ||
|
|
0e5bcbd9f5 | ||
|
|
a7d9bb34fc | ||
|
|
fd65985e9d | ||
|
|
254166e7c1 | ||
|
|
9c5b47421a | ||
|
|
3b51b8ab2e | ||
|
|
9b3f1bfffd | ||
|
|
5ea639d9b2 | ||
|
|
9c926ed126 | ||
|
|
7b03f0ad21 | ||
|
|
0a549945b5 | ||
|
|
0be49c0620 | ||
|
|
186c132151 | ||
|
|
a4f696620b | ||
|
|
184ab66cde | ||
|
|
fbe24fcdb6 | ||
|
|
787edb1bf9 | ||
|
|
385c29b655 | ||
|
|
9477fe0c00 | ||
|
|
531370c4ce | ||
|
|
e299b21e94 | ||
|
|
1f79efcd62 | ||
|
|
e3682ba18c | ||
|
|
a8e0effb32 |
@@ -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
|
||||
@@ -281,10 +281,10 @@ jobs:
|
||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||
# bound runtime.
|
||||
server-tests-macos:
|
||||
name: server-tests (macos-14, file observation)
|
||||
name: server-tests (macos-latest, file observation)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: macos-14
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -100,10 +100,10 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
- runner: macos-latest
|
||||
electron_arch: arm64
|
||||
node_heap_mb: 4096
|
||||
- runner: macos-15-intel
|
||||
- runner: macos-26-intel
|
||||
electron_arch: x64
|
||||
node_heap_mb: 8192
|
||||
permissions:
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
run: nix build .#desktop -o result-desktop
|
||||
|
||||
build-desktop-darwin:
|
||||
runs-on: macos-14
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
|
||||
|
||||
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
||||
|
||||
## Local build + install
|
||||
|
||||
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
|
||||
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
|
||||
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
|
||||
regression renders real gradient pixels across growing and shrinking layouts, including a nested
|
||||
group. From `packages/app/android`, run it on your selected emulator:
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
|
||||
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||
It backports
|
||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||
|
||||
@@ -493,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
|
||||
|
||||
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
||||
|
||||
## Outbound networking
|
||||
|
||||
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
|
||||
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
|
||||
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
|
||||
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
|
||||
are treated as unset, with one warning naming the variable in `daemon.log`.
|
||||
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
|
||||
`ALL_PROXY` configurations are not supported.
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
|
||||
@@ -65,6 +65,7 @@ per-agent examples and provider-specific keys.
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||
@@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
|
||||
|
||||
---
|
||||
|
||||
## OpenCode with an existing v2 server
|
||||
|
||||
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"opencode": {
|
||||
"env": {
|
||||
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
|
||||
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
|
||||
|
||||
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
|
||||
|
||||
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
|
||||
|
||||
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
@@ -232,7 +262,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
|
||||
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
|
||||
|
||||
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
|
||||
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:<provider-id>`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -260,9 +290,9 @@ Paseo passes those variables through to the Codex app-server process **and** map
|
||||
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
|
||||
|
||||
```toml
|
||||
model_provider = "my-codex"
|
||||
model_provider = "paseo:my-codex"
|
||||
|
||||
[model_providers.my-codex]
|
||||
[model_providers."paseo:my-codex"]
|
||||
name = "My Codex"
|
||||
base_url = "https://custom-relay.example.com/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -38,6 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
|
||||
@@ -35,6 +35,20 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
|
||||
|
||||
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
|
||||
|
||||
For Android background fold recovery, install the release APK on a foldable AVD, open a
|
||||
connected workspace, close the keyboard, and run:
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
|
||||
```
|
||||
|
||||
The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app,
|
||||
changes posture, resumes immediately, and asserts compact/wide workspace controls without
|
||||
restarting the process. It writes snapshots and native failure state under
|
||||
`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a
|
||||
production package. This needs a native rebuild when the Unistyles patch changes; a Metro
|
||||
reload cannot update it.
|
||||
|
||||
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
|
||||
with an empty prompt, hide its keyboard, and run:
|
||||
|
||||
|
||||
+1
-1
@@ -550,7 +550,7 @@ string is never inline SVG or a URL.
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
|
||||
@@ -52,6 +52,47 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Terminal ANSI replay
|
||||
|
||||
Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy:
|
||||
use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show
|
||||
only subsequent live output, without the existing screen or history.
|
||||
|
||||
`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source
|
||||
advertising the capability. The SDK keeps the established subscribe request against older
|
||||
ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults
|
||||
to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path.
|
||||
Explicit caller restore modes and history counts pass through unchanged. JSON snapshot
|
||||
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
|
||||
Terminals require `terminal-restore-modes`; older hosts show an update message without attaching.
|
||||
|
||||
## Binary chunking
|
||||
|
||||
`binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1`
|
||||
negotiate connection-scoped framing. Application serializers and routed envelopes use
|
||||
the same boundary. Small messages and handshakes keep their existing representation.
|
||||
Older peers retain existing frame limits.
|
||||
|
||||
The reserved binary opcode `0x31` is followed by version `1`, original kind
|
||||
(`0` text, `1` binary), and START/END flag bits `0`/`1`. Remaining bytes are an
|
||||
unchanged slice. There is no total length or chunk count; a future receiver can
|
||||
consume slices incrementally. Today the receiver buffers up to the existing
|
||||
100 MiB logical limit and decodes UTF-8 only after END. Transfers cannot interleave.
|
||||
Disconnect clears partial state and negotiation.
|
||||
|
||||
The lower transport supplies its plaintext frame budget, including encryption
|
||||
overhead. The sender waits for drain between chunks while ordinary messages keep
|
||||
the existing delivery path. Slow-client protections count messages queued behind
|
||||
an active transfer as well as bytes already in the socket.
|
||||
The framing module never selects an encryption construction.
|
||||
|
||||
Native uploads retain the platform's existing queue limits. React Native's
|
||||
WebSocket does not report its queued bytes, so the client cannot wait for native
|
||||
drain. Android's installed OkHttp transport closes when a send would put more
|
||||
than 16 MiB in its queue. The native transport disables outbound chunking, including
|
||||
on routed channels, and retains its existing single-frame admission errors.
|
||||
Inbound chunking remains enabled; daemon-to-native responses use the daemon's paced writes.
|
||||
|
||||
## Owned observations
|
||||
|
||||
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu
|
||||
|
||||
```
|
||||
pty (node-pty, forked worker process)
|
||||
→ headless xterm parse (worker, snapshot fidelity)
|
||||
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
|
||||
→ process.send IPC → daemon main process
|
||||
→ headless xterm parse (worker, ANSI replay fidelity)
|
||||
→ TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
|
||||
→ framed local socket → daemon main process
|
||||
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
|
||||
→ binary ws frame (2-byte header + raw bytes)
|
||||
→ client decode (daemon-client.ts) → stream router → emulator runtime
|
||||
@@ -19,11 +19,15 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
|
||||
## Invariants (the easy-to-break ones)
|
||||
|
||||
- **Replay is incremental across the daemon and SDK.** Capable clients request their configured history, bounded by [worker retention](terminal-worker.md#process-model). Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
|
||||
- **Replay serialization reads xterm directly.** The [worker](terminal-worker.md#process-model) uses its existing buffer; text capture translates only the requested rows, and readiness scans row strings. Do not rebuild a full cell-object grid. Serialization still allocates a whole ANSI string and UTF-8 bytes before bounded delivery; chunking does not bound those allocations.
|
||||
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
|
||||
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal.
|
||||
- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay.
|
||||
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
|
||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
||||
- **The input-mode tracker runs once per process boundary, not per hop.** The worker commits its authoritative tracker with xterm parsing before publishing the corresponding revision; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
|
||||
- **Snapshot catch-up is backpressure-gated.** After more than 256 KiB of output, a stream catches up only when its transport reports more than 4 MiB queued, or exposes no backpressure reading. A draining client streams continuously. Pressure recovery sends at most 500 recent history rows plus the screen, preserving smaller explicit demands including zero; even a live-only subscription needs this resynchronization. It does not change the subscription's configured demand: later attach/reconnect restores still request the full configured history. The recent window avoids repeatedly serializing all retained history for a slow viewer.
|
||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Terminal rendering
|
||||
|
||||
Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/headless` buffer in JS and draw it with the `PaseoTerminal` native view in `packages/app/modules/paseo-terminal`: CoreText on iOS, `Canvas.drawTextRun` on Android. `src/terminal/native-renderer/terminal-frame.ts` turns the visible buffer into frames, and both native views take the same props.
|
||||
|
||||
## xterm owns the grid
|
||||
|
||||
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
|
||||
|
||||
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
||||
|
||||
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
|
||||
|
||||
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
|
||||
|
||||
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
|
||||
|
||||
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
|
||||
|
||||
## Ink outside the row
|
||||
|
||||
Marks stacked above or below a letter, Arabic letter tails and some fallback glyphs ink into the rows next to theirs. Neither view clips a row to its own bounds, because a row clip also cuts marks and Arabic dots. Rows draw top to bottom, so a later row's cell backgrounds cover the ink that reaches into it.
|
||||
|
||||
Android redraws every row on each frame, so overhanging ink cannot go stale. Its row `RenderNode`s are created with `setClipToBounds(false)`. The default would clip only unselected rows, because selected rows are drawn straight onto the view's canvas.
|
||||
|
||||
iOS repaints only the rows that changed. Each `TerminalRowLayout` records how far its glyphs ink above and below the row. A changed row invalidates its old and new ink extent, and `draw(_:)` redraws every row whose ink extent meets the dirty rect. Invalidating only the row's own rect left an erased row's marks in the row next to it, erased a neighbour's marks when a row was rewritten, and showed or hid overhang depending on which rows changed together.
|
||||
|
||||
## Mixed-direction text
|
||||
|
||||
Rows draw left to right in logical order on both platforms. Terminal bidi escape modes are not implemented. On iOS the row needs both the `kCTWritingDirection` override and an LTR paragraph base direction. Without the base direction, bidi rule L1 moves trailing spaces ahead of a row that starts with Arabic or Hebrew, and the whole row shifts.
|
||||
|
||||
Arabic still gets the contextual form its logical neighbours select, but in left-to-right order, so letters join on the wrong side. Correct visual Arabic needs implicit bidi per row: reorder RTL runs for display and map cursor, selection and hit-testing through the visual-to-logical index. That changes which column a character appears in, so it has to be decided as product behaviour first. Shaping alone cannot fix it.
|
||||
|
||||
## Render regression
|
||||
|
||||
The scripts live in `packages/app/e2e/mobile/terminal-rendering`. Run them from `packages/app` on the daemon's host, starting from an idle, focused terminal with an empty prompt.
|
||||
|
||||
`render.mjs` prints a mixed-script fixture, screenshots it with agent-device, and checks the geometry with `check.mjs`. The check rebuilds the expected buffer with `@xterm/headless` and measures the grid from a ruler row. It checks that:
|
||||
|
||||
- every grapheme cluster has ink inside its own columns, and blank cells stay blank;
|
||||
- on the accent rows, every cell is drawn in its own colour, including cells inside one cluster;
|
||||
- the cursor is drawn after the text;
|
||||
- every row is exposed as `terminal-row-<n>`;
|
||||
- selecting one cell of a conjunct highlights only that cell.
|
||||
|
||||
On iOS the screenshot is taken at 3 pixels per point (`PASEO_TERMINAL_RENDERING_PIXEL_DENSITY`), because the simulator's default is one pixel per point and a glyph is then about 12 pixels. Focus the terminal before a run: with the simulator's hardware keyboard connected the key bar shows without a focused input, and `type` fails. In a debug build, the dev client's floating gear button covers the right end of the first row, so ruler cells there fail.
|
||||
|
||||
```bash
|
||||
PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs
|
||||
PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs
|
||||
```
|
||||
|
||||
Geometry passes when a cell holds the wrong glyph. `identity.mjs` judges each cluster of a `render.mjs` run against pango's rendering of the same characters in the device's fonts. The reference is cut at the edges of the cluster's columns, placed as the renderer places it, because glyphs such as Devanagari letters ink past their advance and the device cuts them there too. A shape passes when its coverage correlates with its own reference at 0.6 or more, and no other cluster of the corpus matches it better. Box and block characters are judged against their Unicode definition. Shades are judged by even density over a 4×4 grid, because the device and pango fall back to different shade fonts. Clusters that pango draws without ink are reported as unjudged instead: flags on both platforms and Apple Color Emoji. The `rect` and `swap` controls fill every cluster with white, or judge each one against another cluster. Both must fail every cluster, which shows the oracle refuses ink that is present but wrong. An Arabic letter is compared with all four of its positional forms, so the oracle shows the right letter is drawn, not that it has the right form. Ink that a neighbouring row draws into a cluster's cells counts as that cluster's ink. On iOS the tail of `م` in the Arabic row reaches one pixel into the `न` below it, so `न` fails although both rows are drawn correctly.
|
||||
|
||||
```bash
|
||||
FONTCONFIG_FILE=fonts.conf node e2e/mobile/terminal-rendering/identity.mjs <run dir> [none|rect|swap]
|
||||
```
|
||||
|
||||
`fonts.conf` points fontconfig at the device's font files and lists, for `monospace`, the terminal font followed by the device's fallbacks for Hangul, Arabic, Devanagari, CJK, emoji and symbols:
|
||||
|
||||
- **Android:** pull the fonts from `/system/fonts` with `adb pull`. The terminal font is Droid Sans Mono.
|
||||
- **iOS:** copy the simulator runtime's fonts from `RuntimeRoot/System/Library/Fonts`. The terminal font is Menlo. PingFang stores its outlines in Apple's `hvgl` table, which FreeType cannot draw, so the CJK references come from Apple SD Gothic Neo instead. `語` then scores below the threshold although the device draws it correctly.
|
||||
|
||||
`overhang.mjs` prints rows whose marks reach the rows next to them. In one update it writes into two rows that hold a neighbour's marks and erases two rows whose marks reach a blank row. The marks must be drawn before the update, stay drawn in the written rows, and leave no ink behind the erased rows. Run it like `render.mjs`.
|
||||
|
||||
`reattach.mjs` checks the restore size. It changes the PTY size by opening the keyboard (`RESIZE=keyboard`) or folding an Android foldable (`RESIZE=fold`), relaunches the app, and compares the client's rows with `paseo terminal capture`. A trial where no terminal rows appear, for example after a launch crash, counts as never attached and fails the run.
|
||||
+15
-1
@@ -15,6 +15,8 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
## Record, socket and secret
|
||||
@@ -40,10 +42,22 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
|
||||
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
|
||||
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
|
||||
|
||||
## Display replay
|
||||
|
||||
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
|
||||
|
||||
ConPTY renders a console rather than forwarding every program escape sequence unchanged. A program write callback does not establish that its hidden cursor move or input modes reached the worker. Test exact ANSI fidelity at the serializer boundary; PTY integration compares the initial replay plus committed observed output at the capture cut, including startup modes.
|
||||
|
||||
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
|
||||
|
||||
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
|
||||
|
||||
The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
|
||||
|
||||
## Running from its own copy
|
||||
|
||||
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
|
||||
|
||||
+10
-4
@@ -21,10 +21,16 @@ The invariants are:
|
||||
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||
> through backward pagination.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
|
||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||
tool payload.
|
||||
Every item is bounded before it enters either delivery path, because a timeline page is one
|
||||
WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get
|
||||
64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a
|
||||
`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up
|
||||
to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker
|
||||
element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The
|
||||
budget does not bound wide objects or multi-byte text. The same
|
||||
bounded item is used for runtime timeline rows and live stream events. Provider history hydration
|
||||
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
|
||||
again to assistant and reasoning rows the projection merges from many chunks.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
|
||||
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
|
||||
|
||||
Generated
+32
@@ -14740,6 +14740,12 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-serialize": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||
@@ -37703,6 +37709,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"babel-preset-expo": "~57.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
@@ -40682,6 +40689,7 @@
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -40690,6 +40698,7 @@
|
||||
"esbuild": "^0.25.12",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
@@ -40707,6 +40716,7 @@
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.8",
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
@@ -41280,6 +41290,15 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -41349,6 +41368,19 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||
|
||||
+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",
|
||||
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
version: nativeReleaseVersion.appVersion,
|
||||
orientation: "portrait",
|
||||
orientation: "default",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "paseo",
|
||||
userInterfaceStyle: "automatic",
|
||||
|
||||
@@ -59,3 +59,38 @@ describe("Android app config", () => {
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("follows system rotation on Android", async () => {
|
||||
const manifest = await resolveAndroidManifest();
|
||||
const activity = manifest.application[0].activity.find(
|
||||
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
|
||||
);
|
||||
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
|
||||
}, 30_000);
|
||||
|
||||
it("allows all orientations on iPhone and iPad", async () => {
|
||||
const { mkdtemp, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
|
||||
try {
|
||||
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
|
||||
await compileModsAsync(exp, {
|
||||
projectRoot: root,
|
||||
introspect: true,
|
||||
platforms: ["ios"],
|
||||
assertMissingModProviders: false,
|
||||
});
|
||||
const plist = exp._internal.modResults.ios.infoPlist;
|
||||
const orientations = [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationPortraitUpsideDown",
|
||||
"UIInterfaceOrientationLandscapeLeft",
|
||||
"UIInterfaceOrientationLandscapeRight",
|
||||
];
|
||||
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
|
||||
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "nested-file-link-",
|
||||
title: "Nested file links",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse:
|
||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||
"# Nested file content\n\nDetails\n",
|
||||
);
|
||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||
await returnToNestedLinks(page);
|
||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||
await returnToNestedLinks(page);
|
||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
attachFileFromMenu,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
} from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
@@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
||||
const requests = recordCommandRequestsByAgentId(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "suggested-command-",
|
||||
title: "Suggested commands",
|
||||
initialPrompt: "Suggest `/mock` without running it",
|
||||
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
||||
await expect(message).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await attachFileFromMenu(page, {
|
||||
name: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("attachment"),
|
||||
});
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await message.getByRole("link", { name: "Run mock" }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await composerLocator(page).fill("draft");
|
||||
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
||||
element.setSelectionRange(0, 0),
|
||||
);
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await composerLocator(page).pressSequentially("argument");
|
||||
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
||||
await composerLocator(page).fill("/");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toContainText("/mock");
|
||||
expect(requests).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ function visibleMainPane(page: Page) {
|
||||
}
|
||||
|
||||
function composerChangesPill(page: Page) {
|
||||
return page.getByTestId("composer-diff-stat-pill");
|
||||
return page.getByRole("button", { name: "Open Changes tab", exact: true });
|
||||
}
|
||||
|
||||
async function revealComposerChangesInExplorer(page: Page) {
|
||||
@@ -498,6 +498,7 @@ test.describe("send review feedback", () => {
|
||||
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
||||
const other = await seedChangedAgent("review-feedback-other-");
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
openDaemonOverview,
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
readWorkerPid,
|
||||
openLongLivedTerminal,
|
||||
listTerminalIds,
|
||||
suspendDaemonProcess,
|
||||
resumeDaemonProcess,
|
||||
daemonProcessCanWrite,
|
||||
readTerminalProcessIds,
|
||||
} from "../support/helpers/daemon-lifecycle";
|
||||
|
||||
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
||||
@@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
|
||||
// A stopped worker deterministically outlives the old helper's five-second
|
||||
// supervisor-only kill and logs into its deleted home when resumed.
|
||||
cleanupTest(
|
||||
"isolated cleanup prevents a stopped worker from recreating its deleted home",
|
||||
async () => {
|
||||
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
const workerPid = await readWorkerPid(daemon);
|
||||
try {
|
||||
suspendDaemonProcess(workerPid);
|
||||
await daemon.close();
|
||||
resumeDaemonProcess(workerPid);
|
||||
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
|
||||
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||
} finally {
|
||||
resumeDaemonProcess(workerPid);
|
||||
await daemon.close();
|
||||
await rm(daemon.paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cleanupTest(
|
||||
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
|
||||
async () => {
|
||||
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
const supervisorPid = daemon.getPid()!;
|
||||
const workerPid = await readWorkerPid(daemon);
|
||||
try {
|
||||
await openLongLivedTerminal(daemon);
|
||||
const terminalProcesses = await readTerminalProcessIds(daemon);
|
||||
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
|
||||
suspendDaemonProcess(supervisorPid);
|
||||
await daemon.close();
|
||||
const writerStates = await Promise.all(
|
||||
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
|
||||
);
|
||||
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
|
||||
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||
} finally {
|
||||
resumeDaemonProcess(supervisorPid);
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -81,6 +81,8 @@ for (const folder of [
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
"examples/next.js/",
|
||||
"examples/next.js",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -92,6 +94,9 @@ for (const folder of [
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
@@ -97,6 +98,7 @@ test.describe("New workspace composer draft", () => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
|
||||
try {
|
||||
await startWithoutRememberedModel(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
@@ -104,6 +106,7 @@ test.describe("New workspace composer draft", () => {
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
});
|
||||
|
||||
await chooseModel(page, "mock", "Ten second stream");
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
const createButton = page.getByTestId("workspace-create-submit");
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
expectTerminalCursor,
|
||||
getTerminalBufferText,
|
||||
waitForTerminalContent,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
|
||||
interface AttachOverlayProbeWindow extends Window {
|
||||
@@ -59,4 +64,65 @@ test.describe("retained terminal tab streams", () => {
|
||||
|
||||
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
|
||||
});
|
||||
|
||||
for (const { count, retainedMarker } of [
|
||||
{ count: 3000, retainedMarker: "history-0001界🙂" },
|
||||
{ count: 12050, retainedMarker: "history-3000界🙂" },
|
||||
]) {
|
||||
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
|
||||
});
|
||||
const terminal = await harness.createTerminal({
|
||||
name: `history-${count}`,
|
||||
command: "/bin/sh",
|
||||
args: [
|
||||
"-lc",
|
||||
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
|
||||
],
|
||||
});
|
||||
const last = `history-${String(count).padStart(4, "0")}界🙂`;
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
await page.reload();
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
const restored = await getTerminalBufferText(page);
|
||||
expect(restored).toContain(retainedMarker);
|
||||
expect(restored.match(/history-\d{4,5}/g)?.length).toBeGreaterThanOrEqual(
|
||||
Math.min(count, 10000),
|
||||
);
|
||||
if (count > 10000) expect(restored).not.toContain("history-0001界🙂");
|
||||
});
|
||||
}
|
||||
for (const input of [
|
||||
{ name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true },
|
||||
{
|
||||
name: "program blinking underline",
|
||||
presentation: "\x1b[3 q",
|
||||
shape: "underline" as const,
|
||||
blinking: true,
|
||||
},
|
||||
]) {
|
||||
test(`reattach preserves the ${input.name} cursor`, async ({ page }) => {
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "cursor-presentation",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
|
||||
],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
|
||||
await expectTerminalCursor(page, input);
|
||||
await page.reload();
|
||||
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
|
||||
await expectTerminalCursor(page, input);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
openUsageFromIcon,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
closeUsage,
|
||||
expectNoUsageItem,
|
||||
expectPinnedUsage,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
test("Check usage applies to the host and Turn on restores cards", async ({
|
||||
page,
|
||||
e2eWorkerClient,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
|
||||
try {
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await openUsageFromIcon(page);
|
||||
await usage.waitForListRequests(1);
|
||||
await openUsageOptions(page);
|
||||
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
|
||||
await closeUsageOptions(page);
|
||||
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
|
||||
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
|
||||
await expectNoUsageItem(page);
|
||||
const before = usage.listRequests().length;
|
||||
await closeUsage(page);
|
||||
await page.reload();
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
|
||||
expect(usage.listRequests()).toHaveLength(before);
|
||||
await page.getByRole("button", { name: "Turn on", exact: true }).click();
|
||||
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
|
||||
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
|
||||
// A separate client changes the policy, as another device would.
|
||||
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
|
||||
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
|
||||
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
|
||||
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
|
||||
}
|
||||
});
|
||||
|
||||
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await installUsageReportsFixture(page, {
|
||||
usageSettingSupported: false,
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -441,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-usage-modal");
|
||||
});
|
||||
|
||||
test("hidden usage sends no requests until opened", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterItemSetting(page, "usage", false);
|
||||
await leaveSettings(page);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
await openUsageFromIcon(page);
|
||||
await usage.waitForListRequests(1);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("visible usage summary requests reports on load", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Run a release build with a connected workspace open and the keyboard hidden.
|
||||
// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const serial = process.env.ANDROID_SERIAL;
|
||||
assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator");
|
||||
const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug";
|
||||
const session = "android-fold";
|
||||
const artifacts = resolve(".dev/agent-device-artifacts/android-fold");
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"),
|
||||
};
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function run(binary, args) {
|
||||
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 });
|
||||
}
|
||||
function adb(...args) {
|
||||
return run("adb", ["-s", serial, ...args]);
|
||||
}
|
||||
function ad(...args) {
|
||||
return run("agent-device", [...args, "--session", session]);
|
||||
}
|
||||
function pid() {
|
||||
return adb("shell", "pidof", appId).trim();
|
||||
}
|
||||
function open() {
|
||||
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
|
||||
}
|
||||
function stateId(name) {
|
||||
const states = adb("shell", "cmd", "device_state", "print-states");
|
||||
const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`));
|
||||
assert(match, `Emulator must expose the ${name} posture: ${states}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function expectLayout(posture) {
|
||||
const compact = posture === "CLOSED";
|
||||
const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll";
|
||||
const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger";
|
||||
const deadline = Date.now() + 15_000;
|
||||
let nodes = [];
|
||||
do {
|
||||
let data;
|
||||
try {
|
||||
({ data } = JSON.parse(ad("snapshot", "-i", "--json")));
|
||||
} catch (error) {
|
||||
// During display handoff Android briefly has no capturable app window.
|
||||
if (!error.stdout?.includes("insufficient foreground app content")) throw error;
|
||||
await delay(200);
|
||||
continue;
|
||||
}
|
||||
assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo");
|
||||
nodes = data.nodes;
|
||||
if (
|
||||
nodes.some((node) => node.identifier === expectedId) &&
|
||||
!nodes.some((node) => node.identifier === unexpectedId)
|
||||
) {
|
||||
writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2));
|
||||
console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`);
|
||||
return;
|
||||
}
|
||||
await delay(200);
|
||||
} while (Date.now() < deadline);
|
||||
writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2));
|
||||
writeFileSync(
|
||||
resolve(artifacts, "native-failure.txt"),
|
||||
adb("shell", "dumpsys", "activity", "top"),
|
||||
);
|
||||
assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`);
|
||||
}
|
||||
|
||||
const closed = stateId("CLOSED");
|
||||
const opened = stateId("OPENED");
|
||||
const initialState = adb("shell", "cmd", "device_state", "state").match(
|
||||
/Committed state: DeviceState\{identifier=(\d+)/,
|
||||
)?.[1];
|
||||
assert(initialState, "Could not read the initial fold state");
|
||||
|
||||
try {
|
||||
adb("shell", "cmd", "device_state", "state", opened);
|
||||
open();
|
||||
await expectLayout("OPENED");
|
||||
const initialPid = pid();
|
||||
for (const [posture, id] of [
|
||||
["CLOSED", closed],
|
||||
["OPENED", opened],
|
||||
]) {
|
||||
adb("shell", "input", "keyevent", "KEYCODE_HOME");
|
||||
adb("shell", "cmd", "device_state", "state", id);
|
||||
// Resume immediately: waiting for configuration delivery hides the regression.
|
||||
open();
|
||||
await expectLayout(posture);
|
||||
assert.equal(pid(), initialPid, "Fold/resume must preserve the app process");
|
||||
}
|
||||
} finally {
|
||||
adb("shell", "cmd", "device_state", "state", initialState);
|
||||
ad("close");
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import xterm from "@xterm/headless";
|
||||
import { PNG } from "pngjs";
|
||||
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
||||
|
||||
/** A pixel is ink when one channel differs from the terminal background by more than this. */
|
||||
const INK_THRESHOLD = 70;
|
||||
/** Ink pixels a printed grapheme cluster needs inside its own columns. */
|
||||
const MIN_CLUSTER_INK = 3;
|
||||
/** How far a pixel may sit from the background-to-color blend line and still count as that color. */
|
||||
const BLEND_TOLERANCE = 40;
|
||||
/** Pixels of another cell's color tolerated in a cell, for antialiasing at the cell edge. */
|
||||
const MAX_FOREIGN_INK = 2;
|
||||
|
||||
const graphemes = new Intl.Segmenter("und", { granularity: "grapheme" });
|
||||
|
||||
/**
|
||||
* Checks a screenshot of the fixture against the cells xterm assigns: every printed grapheme
|
||||
* cluster has ink inside its own columns, blank cells stay blank, and the cursor sits right after
|
||||
* the text. A cluster that spans cells, such as a Devanagari conjunct, is shaped as one unit, so
|
||||
* it is judged across all its columns. On rows that use the fixture's accent color, every cell
|
||||
* must be drawn in its own color, including cells that share a shaped cluster.
|
||||
*
|
||||
* These are geometry and color checks: they cannot tell a correct glyph from a wrong one.
|
||||
*
|
||||
* `rows` maps a terminal row index to its rect in screenshot pixels.
|
||||
*/
|
||||
export async function checkTerminalScreenshot({ png, size, rows }) {
|
||||
const terminal = await fixtureTerminal(size);
|
||||
assert.ok(rows.get(0), "The ruler row is not exposed as terminal-row-0");
|
||||
const grid = measureGrid(png, rows.get(0));
|
||||
const { failures, slots } = checkRows({ png, terminal, rows, grid });
|
||||
terminal.dispose();
|
||||
return { grid, failures, slots };
|
||||
}
|
||||
|
||||
/** Where the harness selects: the first cell of the conjunct that starts the `select` line. */
|
||||
export async function selectionTarget({ size }) {
|
||||
const terminal = await fixtureTerminal(size);
|
||||
const row = lineNames(terminal.buffer.active, terminal.buffer.active.cursorY + 1).indexOf(
|
||||
SELECT_LINE,
|
||||
);
|
||||
terminal.dispose();
|
||||
return { row, col: 0, clusterColumns: 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a screenshot taken with only the first cell of the `select` conjunct selected: that cell
|
||||
* has the selection background and the conjunct's second, unselected cell keeps its accent color.
|
||||
*/
|
||||
export function checkPartialSelection({ png, rows, grid, target }) {
|
||||
const rect = rows.get(target.row);
|
||||
if (!rect) return [{ row: target.row, kind: "missing-row" }];
|
||||
const cell = (col) => ({
|
||||
left: grid.left + col * grid.cellWidth + 1,
|
||||
right: grid.left + (col + 1) * grid.cellWidth - 1,
|
||||
top: rect.y,
|
||||
bottom: rect.y + rect.height,
|
||||
});
|
||||
const selected = cell(target.col);
|
||||
const unselected = cell(target.col + 1);
|
||||
const selectionBackground = pixelAt(png, selected.left + 1, selected.top + 1);
|
||||
const unselectedBackground = pixelAt(png, unselected.left + 1, unselected.top + 1);
|
||||
if (
|
||||
!differs(selectionBackground, grid.background) ||
|
||||
differs(unselectedBackground, grid.background)
|
||||
) {
|
||||
return [{ row: target.row, col: target.col, kind: "selection-not-set-up" }];
|
||||
}
|
||||
const failures = [];
|
||||
const ownInk = countColor(png, unselected, grid.background, ACCENT);
|
||||
if (ownInk < MIN_CLUSTER_INK) {
|
||||
failures.push({ row: target.row, col: target.col + 1, kind: "unselected-cell-lost-color" });
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function fixtureTerminal(size) {
|
||||
const terminal = new xterm.Terminal({
|
||||
cols: size.columns,
|
||||
rows: size.rows,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
await new Promise((done) =>
|
||||
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
||||
);
|
||||
return terminal;
|
||||
}
|
||||
|
||||
export function writeOverlay(png, slots, path) {
|
||||
const out = new PNG({ width: png.width, height: png.height });
|
||||
png.data.copy(out.data);
|
||||
for (const slot of slots) {
|
||||
const color = slot.ok ? [0, 200, 0] : [255, 0, 0];
|
||||
const { left, right, top, bottom } = slot.box;
|
||||
for (let x = Math.ceil(left); x < right; x += 1) {
|
||||
for (const y of [Math.ceil(top), Math.floor(bottom) - 1]) {
|
||||
const offset = (out.width * y + x) * 4;
|
||||
out.data.set(color, offset);
|
||||
}
|
||||
}
|
||||
if (!slot.ok) {
|
||||
for (let y = Math.ceil(top); y < bottom; y += 1) {
|
||||
for (const x of [Math.ceil(left), Math.floor(right) - 1]) {
|
||||
out.data.set(color, (out.width * y + x) * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(path, PNG.sync.write(out));
|
||||
}
|
||||
|
||||
function inkAt(png, x, y, background) {
|
||||
const offset = (png.width * Math.round(y) + Math.round(x)) * 4;
|
||||
return (
|
||||
Math.abs(png.data[offset] - background[0]) > INK_THRESHOLD ||
|
||||
Math.abs(png.data[offset + 1] - background[1]) > INK_THRESHOLD ||
|
||||
Math.abs(png.data[offset + 2] - background[2]) > INK_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
/** Pixels in `box` that differ from `background` in some channel by more than the ink threshold. */
|
||||
export function countInk(png, box, background) {
|
||||
let count = 0;
|
||||
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
|
||||
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
|
||||
if (inkAt(png, x, y, background)) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function differs(a, b) {
|
||||
return a.some((channel, index) => Math.abs(channel - b[index]) > INK_THRESHOLD);
|
||||
}
|
||||
|
||||
/** Distance from `pixel` to the nearest blend of `background` toward `color`. */
|
||||
function blendDistance(pixel, background, color) {
|
||||
const direction = color.map((channel, index) => channel - background[index]);
|
||||
const length = direction.reduce((sum, value) => sum + value * value, 0);
|
||||
const offset = pixel.map((channel, index) => channel - background[index]);
|
||||
const along = offset.reduce((sum, value, index) => sum + value * direction[index], 0);
|
||||
const alpha = length === 0 ? 0 : Math.min(1, Math.max(0, along / length));
|
||||
return Math.hypot(...offset.map((value, index) => value - alpha * direction[index]));
|
||||
}
|
||||
|
||||
/** Ink pixels in `box` that are a blend of the background toward `color`. */
|
||||
function countColor(png, box, background, color, other) {
|
||||
let count = 0;
|
||||
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
|
||||
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
|
||||
if (!inkAt(png, x, y, background)) continue;
|
||||
const pixel = pixelAt(png, x, y);
|
||||
const distance = blendDistance(pixel, background, color);
|
||||
if (distance > BLEND_TOLERANCE) continue;
|
||||
if (other && blendDistance(pixel, background, other) <= distance) continue;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function pixelAt(png, x, y) {
|
||||
const offset = (png.width * Math.round(y) + Math.round(x)) * 4;
|
||||
return [png.data[offset], png.data[offset + 1], png.data[offset + 2]];
|
||||
}
|
||||
|
||||
/** Reads the grid origin and cell width from a ruler row of `RULER_CELLS` full blocks. */
|
||||
export function measureGrid(png, rulerRect) {
|
||||
const y = rulerRect.y + rulerRect.height / 2;
|
||||
const background = pixelAt(png, rulerRect.x + rulerRect.width - 2, y);
|
||||
let left = -1;
|
||||
let right = -1;
|
||||
for (let x = Math.ceil(rulerRect.x); x < rulerRect.x + rulerRect.width; x += 1) {
|
||||
if (!inkAt(png, x, y, background)) continue;
|
||||
if (left < 0) left = x;
|
||||
right = x + 1;
|
||||
}
|
||||
assert.ok(left >= 0, "The ruler row has no ink");
|
||||
const foreground = pixelAt(png, Math.round((left + right) / 2), y);
|
||||
return { left, cellWidth: (right - left) / RULER_CELLS, background, foreground };
|
||||
}
|
||||
|
||||
function lineNames(buffer, count) {
|
||||
const names = fixtureLines(0).map((line) => line.name);
|
||||
const result = [];
|
||||
let index = -1;
|
||||
for (let row = 0; row < count; row += 1) {
|
||||
if (!buffer.getLine(row).isWrapped) index += 1;
|
||||
result.push(names[index] ?? `line-${index}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The row's printed cells, consecutive cells of one grapheme cluster grouped together. */
|
||||
function rowClusters(line, columns, cell) {
|
||||
const cells = [];
|
||||
let text = "";
|
||||
for (let col = 0; col < columns; col += 1) {
|
||||
line.getCell(col, cell);
|
||||
const width = cell.getWidth();
|
||||
if (width === 0) continue;
|
||||
const chars = cell.getChars() || " ";
|
||||
cells.push({
|
||||
col,
|
||||
width,
|
||||
chars,
|
||||
offset: text.length,
|
||||
painted: cell.isInverse() || !cell.isBgDefault(),
|
||||
accent: cell.isFgRGB() && cell.getFgColor() === rgbValue(ACCENT),
|
||||
});
|
||||
text += chars;
|
||||
}
|
||||
const clusterEnds = [...graphemes.segment(text)].map(
|
||||
(segment) => segment.index + segment.segment.length,
|
||||
);
|
||||
const clusters = [];
|
||||
let end = 0;
|
||||
for (const entry of cells) {
|
||||
while (entry.offset >= clusterEnds[end]) end += 1;
|
||||
const last = clusters.at(-1);
|
||||
if (last && last.end === end) last.cells.push(entry);
|
||||
else clusters.push({ end, cells: [entry] });
|
||||
}
|
||||
return clusters.map((cluster) => cluster.cells);
|
||||
}
|
||||
|
||||
function rgbValue([red, green, blue]) {
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
/** Each cell of a cluster that uses the accent color holds ink of its own color only. */
|
||||
function checkClusterColors({ png, cluster, rect, grid }) {
|
||||
const kinds = [];
|
||||
for (const entry of cluster) {
|
||||
const box = {
|
||||
left: grid.left + entry.col * grid.cellWidth + 1,
|
||||
right: grid.left + (entry.col + entry.width) * grid.cellWidth - 1,
|
||||
top: rect.y,
|
||||
bottom: rect.y + rect.height,
|
||||
};
|
||||
const [color, other] = entry.accent ? [ACCENT, grid.foreground] : [grid.foreground, ACCENT];
|
||||
const own = countColor(png, box, grid.background, color, other);
|
||||
const foreign = countColor(png, box, grid.background, other, color);
|
||||
if (own < MIN_CLUSTER_INK || foreign > MAX_FOREIGN_INK) {
|
||||
kinds.push({ col: entry.col, kind: entry.accent ? "accent-color-lost" : "color-leaked" });
|
||||
}
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
function checkRows({ png, terminal, rows, grid }) {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lastRow = buffer.cursorY;
|
||||
const names = lineNames(buffer, lastRow + 1);
|
||||
const failures = [];
|
||||
const slots = [];
|
||||
const cell = buffer.getNullCell();
|
||||
for (let row = 0; row <= lastRow; row += 1) {
|
||||
const rect = rows.get(row);
|
||||
if (!rect) {
|
||||
failures.push({ row, line: names[row], kind: "missing-row" });
|
||||
continue;
|
||||
}
|
||||
const clusters = rowClusters(buffer.getLine(row), terminal.cols, cell);
|
||||
// Color is judged on whole rows that use the accent, so a color leaking into a neighbouring
|
||||
// default-colored cell fails too.
|
||||
const checksColor = clusters.some((cluster) => cluster.some((entry) => entry.accent));
|
||||
for (const cluster of clusters) {
|
||||
const first = cluster[0];
|
||||
const columns = cluster.reduce((sum, entry) => sum + entry.width, 0);
|
||||
const left = grid.left + first.col * grid.cellWidth;
|
||||
const right = left + columns * grid.cellWidth;
|
||||
const box = { left: left + 1, right: right - 1, top: rect.y, bottom: rect.y + rect.height };
|
||||
const chars = cluster.map((entry) => entry.chars).join("");
|
||||
const isCursor = row === lastRow && first.col === buffer.cursorX;
|
||||
let kind = null;
|
||||
if (isCursor) {
|
||||
const covered = countInk(png, box, grid.background);
|
||||
const area = (box.right - box.left) * (box.bottom - box.top);
|
||||
if (covered < area * 0.25) kind = "cursor-not-drawn";
|
||||
} else if (chars.trim() !== "") {
|
||||
if (countInk(png, box, grid.background) < MIN_CLUSTER_INK) kind = "missing-ink";
|
||||
} else if (!cluster.some((entry) => entry.painted)) {
|
||||
const quarter = (right - left) / 4;
|
||||
const center = { ...box, left: left + quarter, right: right - quarter };
|
||||
if (countInk(png, center, grid.background) > 0) kind = "ink-in-blank";
|
||||
}
|
||||
const colorFailures =
|
||||
kind === null && checksColor && chars.trim() !== ""
|
||||
? checkClusterColors({ png, cluster, rect, grid })
|
||||
: [];
|
||||
slots.push({
|
||||
row,
|
||||
line: names[row],
|
||||
col: first.col,
|
||||
width: columns,
|
||||
chars,
|
||||
isCursor,
|
||||
box,
|
||||
ok: kind === null && colorFailures.length === 0,
|
||||
});
|
||||
if (kind) failures.push({ row, line: names[row], col: first.col, char: chars, kind });
|
||||
for (const failure of colorFailures) {
|
||||
failures.push({ row, line: names[row], char: chars, ...failure });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { failures, slots };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Terminal output covering the scripts and styles the native renderer must place on the grid.
|
||||
// Each entry is one line of output; `ruler` is drawn with full blocks so the harness can read
|
||||
// the grid origin and cell width from pixels, independent of the renderer under test.
|
||||
|
||||
const ESC = "\u001b";
|
||||
const sgr = (codes, text) => `${ESC}[${codes}m${text}${ESC}[0m`;
|
||||
|
||||
export const RULER_CELLS = 20;
|
||||
/** A foreground no theme uses, so its pixels can be told apart from default text. */
|
||||
export const ACCENT = [255, 64, 160];
|
||||
const accent = (text) => sgr(`38;2;${ACCENT.join(";")}`, text);
|
||||
/**
|
||||
* The `select` line starts with a conjunct whose first cell the harness selects on its own. Its
|
||||
* second cell uses the accent, which no theme's selection foreground matches.
|
||||
*/
|
||||
export const SELECT_LINE = "select";
|
||||
|
||||
export function fixtureLines(columns) {
|
||||
const wrapSyllables = Math.ceil(columns / 2) + 6;
|
||||
return [
|
||||
{ name: "ruler", text: "█".repeat(RULER_CELLS) },
|
||||
{ name: "ascii", text: "ASCII abcdefghij 0123456789 end" },
|
||||
{ name: "hangul-mixed", text: "한글 문장 Hello end" },
|
||||
{ name: "hangul-color-boundary", text: `안녕하세요${sgr(31, "NEXT")} end` },
|
||||
{ name: "hangul-bold-boundary", text: `${sgr(1, "안녕하세요")}NEXT end` },
|
||||
{ name: "hangul-decomposed", text: "한글 end" },
|
||||
{ name: "cjk", text: "日本語テキスト 中文字符 end" },
|
||||
{ name: "combining", text: "é ä ñ x̣̂ end" },
|
||||
{ name: "arabic", text: "مرحبا لا 123 abc end" },
|
||||
{ name: "devanagari", text: "नमस्ते क्षत्रिय end" },
|
||||
{ name: "emoji", text: "👍 ❤️ 👋🏽 👩💻 🇰🇷 end" },
|
||||
// One shaped cluster over two cells, colored per cell: the color must follow the cell.
|
||||
{ name: "split-color", text: `क्${accent("ष")} ${accent("क्")}ष ल${accent("ا")} end` },
|
||||
{ name: SELECT_LINE, text: `क्${accent("ष")} end` },
|
||||
{ name: "box", text: "╭──┬──╮ │ab│cd│ ╰──┴──╯ ▀▄▌▐░▒▓ end" },
|
||||
{
|
||||
name: "styles",
|
||||
text: [
|
||||
sgr(1, "bold"),
|
||||
sgr(3, "italic"),
|
||||
sgr(4, "under"),
|
||||
sgr(7, "inverse"),
|
||||
sgr(42, "bg"),
|
||||
sgr(9, "strike"),
|
||||
"end",
|
||||
].join(" "),
|
||||
},
|
||||
{ name: "hangul-wrap", text: "가".repeat(wrapSyllables) },
|
||||
// The last line has no newline: the shell sleeps with the cursor right after it.
|
||||
{ name: "cursor", text: "> 안녕하세요" },
|
||||
];
|
||||
}
|
||||
|
||||
/** The bytes `cat` writes; the tty turns each newline into CR LF. */
|
||||
export function fixtureOutput(columns) {
|
||||
return fixtureLines(columns)
|
||||
.map((line) => line.text)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { PNG } from "pngjs";
|
||||
import { checkTerminalScreenshot } from "./check.mjs";
|
||||
|
||||
// Checks that every printed cluster of a `render.mjs` run is the right glyph. `check.mjs` proves
|
||||
// where ink lands and what color it has; this compares each cluster's ink with pango's (HarfBuzz)
|
||||
// rendering of the same characters in the device's own fonts, so a wrong glyph, tofu, a solid
|
||||
// block, a split conjunct or a cluster from elsewhere fails.
|
||||
//
|
||||
// FONTCONFIG_FILE=<fonts.conf> node e2e/mobile/terminal-rendering/identity.mjs <run dir> [control]
|
||||
//
|
||||
// The fonts.conf lists the device's font files and prefers, for `monospace`, the terminal font
|
||||
// followed by the device's fallbacks; `docs/terminal-rendering.md` says how to build one. Needs
|
||||
// `pango-view` on the PATH.
|
||||
//
|
||||
// control:
|
||||
// none judge the screenshot (default); exits 1 when a cluster fails.
|
||||
// rect first fill every judged cluster's box with white; exits 1 unless every cluster fails.
|
||||
// swap judge each cluster against a different cluster of the corpus; exits 1 unless every
|
||||
// cluster fails.
|
||||
// The two controls prove the oracle refuses ink that is present but wrong.
|
||||
|
||||
/** Coverage grid side used to compare shapes. */
|
||||
const GRID = 16;
|
||||
/** A pixel at or above this ink strength (0..1) is part of a glyph's bounding box. */
|
||||
const INK = 0.25;
|
||||
/** Lowest shape correlation a cluster may have with its own reference. */
|
||||
const THRESHOLD = 0.6;
|
||||
/** How much better another cluster may match before the cluster's own match stops counting. */
|
||||
const MARGIN = 0.02;
|
||||
/** Lines whose cells carry decorations or a custom-drawn ruler; they stay geometry-only. */
|
||||
const SKIPPED_LINES = new Set(["ruler", "styles"]);
|
||||
const ZWJ = "";
|
||||
|
||||
/**
|
||||
* The renderer draws box and block characters to fill the cell, so they are judged against their
|
||||
* Unicode definition. Shades come from the font, whose fallback differs between the device and
|
||||
* pango, so a shade is judged by how evenly and densely it inks its own bounding box. Fonts dither
|
||||
* differently: ░ ▒ ▓ measure 0.16, 0.31, 0.75 in Droid Sans Mono and 0.19, 0.50, 0.81 in Menlo. The
|
||||
* bands split between those, and do not overlap, so one shade never passes as another.
|
||||
*/
|
||||
const CELL_GLYPHS = {
|
||||
"─": { arms: "LR" },
|
||||
"│": { arms: "UD" },
|
||||
"┬": { arms: "LRD" },
|
||||
"┴": { arms: "LRU" },
|
||||
"╭": { arms: "DR" },
|
||||
"╮": { arms: "DL" },
|
||||
"╰": { arms: "UR" },
|
||||
"╯": { arms: "UL" },
|
||||
"▀": { fill: "top" },
|
||||
"▄": { fill: "bottom" },
|
||||
"▌": { fill: "left" },
|
||||
"▐": { fill: "right" },
|
||||
"░": { shade: [0.08, 0.25] },
|
||||
"▒": { shade: [0.25, 0.625] },
|
||||
"▓": { shade: [0.625, 0.9] },
|
||||
};
|
||||
/** A shade must ink each region of this grid over its bounding box within its band. */
|
||||
const SHADE_GRID = 4;
|
||||
/** Regions of the cell, as fractions [left, top, right, bottom], that a line to each edge crosses. */
|
||||
const ARM_REGIONS = {
|
||||
U: [0.3, 0, 0.7, 0.2],
|
||||
D: [0.3, 0.8, 0.7, 1],
|
||||
L: [0, 0.3, 0.2, 0.7],
|
||||
R: [0.8, 0.3, 1, 0.7],
|
||||
};
|
||||
/** [filled, empty] halves of each half block. */
|
||||
const HALVES = {
|
||||
top: [
|
||||
[0, 0, 1, 0.5],
|
||||
[0, 0.5, 1, 1],
|
||||
],
|
||||
bottom: [
|
||||
[0, 0.5, 1, 1],
|
||||
[0, 0, 1, 0.5],
|
||||
],
|
||||
left: [
|
||||
[0, 0, 0.5, 1],
|
||||
[0.5, 0, 1, 1],
|
||||
],
|
||||
right: [
|
||||
[0.5, 0, 1, 1],
|
||||
[0, 0, 0.5, 1],
|
||||
],
|
||||
};
|
||||
|
||||
const [dirArg, control = "none"] = process.argv.slice(2);
|
||||
if (!dirArg || !["none", "rect", "swap"].includes(control)) {
|
||||
throw new Error("Usage: identity.mjs <run dir> [none|rect|swap]");
|
||||
}
|
||||
const dir = resolve(dirArg);
|
||||
const report = JSON.parse(readFileSync(resolve(dir, "report.json"), "utf8"));
|
||||
const png = PNG.sync.read(readFileSync(resolve(dir, "terminal.png")));
|
||||
const rows = new Map(report.rows.map((row) => [row.index, row.rect]));
|
||||
const { grid, slots } = await checkTerminalScreenshot({ png, size: report.size, rows });
|
||||
|
||||
const printed = slots.filter(
|
||||
(slot) => !SKIPPED_LINES.has(slot.line) && !slot.isCursor && slot.chars.trim() !== "",
|
||||
);
|
||||
/** The columns xterm gives each printed cluster. */
|
||||
const columnsOf = new Map(printed.map((slot) => [slot.chars, slot.width]));
|
||||
|
||||
if (control === "rect") {
|
||||
for (const { box } of printed) {
|
||||
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
|
||||
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
|
||||
png.data.set([255, 255, 255, 255], (png.width * y + x) * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ink strength 0..1 of each pixel in a box: the largest channel distance from the background. */
|
||||
function screenshotInk(box) {
|
||||
const left = Math.ceil(box.left);
|
||||
const top = Math.ceil(box.top);
|
||||
const width = Math.floor(box.right) - left;
|
||||
const height = Math.floor(box.bottom) - top;
|
||||
const ink = new Float32Array(width * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (png.width * (top + y) + left + x) * 4;
|
||||
let distance = 0;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
distance = Math.max(
|
||||
distance,
|
||||
Math.abs(png.data[offset + channel] - grid.background[channel]),
|
||||
);
|
||||
}
|
||||
ink[y * width + x] = distance / 255;
|
||||
}
|
||||
}
|
||||
return { ink, width, height };
|
||||
}
|
||||
|
||||
/** Ink of the slot's whole cells, scaled so the foreground color is 1. */
|
||||
function cellInk(slot) {
|
||||
const left = Math.round(grid.left + slot.col * grid.cellWidth);
|
||||
const right = Math.round(grid.left + (slot.col + slot.width) * grid.cellWidth);
|
||||
const cells = screenshotInk({ left, right, top: slot.box.top, bottom: slot.box.bottom });
|
||||
const full =
|
||||
Math.max(...[0, 1, 2].map((c) => Math.abs(grid.foreground[c] - grid.background[c]))) / 255;
|
||||
for (let i = 0; i < cells.ink.length; i += 1) cells.ink[i] = Math.min(1, cells.ink[i] / full);
|
||||
return cells;
|
||||
}
|
||||
|
||||
const scratch = mkdtempSync(resolve(tmpdir(), "terminal-identity-"));
|
||||
/** pango-view sizes its image to the text's advance plus this margin on each side. */
|
||||
const REFERENCE_MARGIN = 48;
|
||||
function pangoInk(text) {
|
||||
const out = resolve(scratch, "reference.png");
|
||||
execFileSync("pango-view", [
|
||||
"--no-display",
|
||||
"--dpi=72",
|
||||
"--font=monospace 96px",
|
||||
`--text=${text}`,
|
||||
"--background=black",
|
||||
"--foreground=white",
|
||||
`--margin=${REFERENCE_MARGIN}`,
|
||||
"-o",
|
||||
out,
|
||||
]);
|
||||
const image = PNG.sync.read(readFileSync(out));
|
||||
const ink = new Float32Array(image.width * image.height);
|
||||
for (let i = 0; i < ink.length; i += 1) {
|
||||
const o = i * 4;
|
||||
ink[i] = Math.max(image.data[o], image.data[o + 1], image.data[o + 2]) / 255;
|
||||
}
|
||||
return {
|
||||
ink,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
advance: image.width - 2 * REFERENCE_MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
/** The terminal font's cell advance at the reference size. */
|
||||
const referenceCell = pangoInk("M").advance;
|
||||
|
||||
/**
|
||||
* The text as the renderer places it in `columns` cells: centred, scaled down when its advance is
|
||||
* wider, and cut at the cells' edges. Glyphs whose ink overhangs their advance, such as Devanagari
|
||||
* headlines, are cut on the device too.
|
||||
*/
|
||||
function referenceInk(text, columns) {
|
||||
const image = pangoInk(text);
|
||||
const box = Math.max(image.advance, columns * referenceCell);
|
||||
const left = Math.round(REFERENCE_MARGIN + (image.advance - box) / 2);
|
||||
const right = Math.round(REFERENCE_MARGIN + (image.advance + box) / 2);
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
if (x < left || x >= right) image.ink[y * image.width + x] = 0;
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
function regionInk({ ink, width, height }, [x0, y0, x1, y1]) {
|
||||
let [sum, max, count] = [0, 0, 0];
|
||||
for (let y = Math.floor(y0 * height); y < Math.ceil(y1 * height); y += 1) {
|
||||
for (let x = Math.floor(x0 * width); x < Math.ceil(x1 * width); x += 1) {
|
||||
const value = ink[y * width + x] ?? 0;
|
||||
sum += value;
|
||||
max = Math.max(max, value);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return { mean: sum / count, max };
|
||||
}
|
||||
|
||||
function inkBox({ ink, width, height }) {
|
||||
let [minX, minY, maxX, maxY] = [width, height, -1, -1];
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (ink[y * width + x] < INK) continue;
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
return maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
/** Whether an unbroken line crosses the region to the cell edge: a row for L/R, a column for U/D. */
|
||||
function lineCrosses({ ink, width, height }, arm) {
|
||||
const [x0, y0, x1, y1] = ARM_REGIONS[arm];
|
||||
const [left, top, right, bottom] = [
|
||||
Math.floor(x0 * width),
|
||||
Math.floor(y0 * height),
|
||||
Math.ceil(x1 * width),
|
||||
Math.ceil(y1 * height),
|
||||
];
|
||||
const horizontal = arm === "L" || arm === "R";
|
||||
const [outerFrom, outerTo, innerFrom, innerTo] = horizontal
|
||||
? [top, bottom, left, right]
|
||||
: [left, right, top, bottom];
|
||||
for (let outer = outerFrom; outer < outerTo; outer += 1) {
|
||||
let unbroken = true;
|
||||
for (let inner = innerFrom; inner < innerTo && unbroken; inner += 1) {
|
||||
const [x, y] = horizontal ? [inner, outer] : [outer, inner];
|
||||
unbroken = (ink[y * width + x] ?? 0) >= 0.5;
|
||||
}
|
||||
if (unbroken) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 1 when the cell matches the character's definition, 0 otherwise. */
|
||||
function cellGlyphScore(slot, spec) {
|
||||
const ink = cellInk(slot);
|
||||
if (spec.arms) {
|
||||
const matches = Object.keys(ARM_REGIONS).every((arm) =>
|
||||
spec.arms.includes(arm) ? lineCrosses(ink, arm) : regionInk(ink, ARM_REGIONS[arm]).max < 0.3,
|
||||
);
|
||||
return matches ? 1 : 0;
|
||||
}
|
||||
if (spec.fill) {
|
||||
const [filled, empty] = HALVES[spec.fill].map((region) => regionInk(ink, region).mean);
|
||||
return filled >= 0.8 && empty <= 0.2 ? 1 : 0;
|
||||
}
|
||||
const box = inkBox(ink);
|
||||
if (!box || box.width < 0.7 * ink.width || box.height < 0.5 * ink.height) return 0;
|
||||
const [x0, y0] = [box.x / ink.width, box.y / ink.height];
|
||||
const [x1, y1] = [(box.x + box.width) / ink.width, (box.y + box.height) / ink.height];
|
||||
const [w, h] = [(x1 - x0) / SHADE_GRID, (y1 - y0) / SHADE_GRID];
|
||||
const regions = Array.from({ length: SHADE_GRID * SHADE_GRID }, (_, index) => {
|
||||
const [gx, gy] = [index % SHADE_GRID, Math.floor(index / SHADE_GRID)];
|
||||
return [x0 + gx * w, y0 + gy * h, x0 + (gx + 1) * w, y0 + (gy + 1) * h];
|
||||
});
|
||||
const means = regions.map((region) => regionInk(ink, region).mean);
|
||||
return means.every((mean) => mean >= spec.shade[0] && mean < spec.shade[1]) ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Coverage of the ink's bounding box, centered in a square that keeps its aspect, at GRID². */
|
||||
function coverage(image) {
|
||||
const box = inkBox(image);
|
||||
if (!box) return null;
|
||||
const side = Math.max(box.width, box.height);
|
||||
const originX = box.x - (side - box.width) / 2;
|
||||
const originY = box.y - (side - box.height) / 2;
|
||||
const vector = new Float32Array(GRID * GRID);
|
||||
const step = side / GRID;
|
||||
const samples = 4;
|
||||
for (let gy = 0; gy < GRID; gy += 1) {
|
||||
for (let gx = 0; gx < GRID; gx += 1) {
|
||||
let sum = 0;
|
||||
for (let sample = 0; sample < samples * samples; sample += 1) {
|
||||
const x = Math.floor(originX + (gx + ((sample % samples) + 0.5) / samples) * step);
|
||||
const y = Math.floor(
|
||||
originY + (gy + (Math.floor(sample / samples) + 0.5) / samples) * step,
|
||||
);
|
||||
sum +=
|
||||
x >= 0 && y >= 0 && x < image.width && y < image.height
|
||||
? image.ink[y * image.width + x]
|
||||
: 0;
|
||||
}
|
||||
vector[gy * GRID + gx] = sum / (samples * samples);
|
||||
}
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
/** Pearson correlation of two coverage vectors; -1 when either has no ink or no contrast. */
|
||||
function correlation(a, b) {
|
||||
if (!a || !b) return -1;
|
||||
const n = a.length;
|
||||
let [ma, mb] = [0, 0];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
ma += a[i];
|
||||
mb += b[i];
|
||||
}
|
||||
ma /= n;
|
||||
mb /= n;
|
||||
let [num, da, db] = [0, 0, 0];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
num += (a[i] - ma) * (b[i] - mb);
|
||||
da += (a[i] - ma) ** 2;
|
||||
db += (b[i] - mb) ** 2;
|
||||
}
|
||||
return da === 0 || db === 0 ? -1 : num / Math.sqrt(da * db);
|
||||
}
|
||||
|
||||
const references = new Map();
|
||||
/**
|
||||
* The cluster alone and with a zero-width joiner before, after and on both sides, so any Arabic
|
||||
* positional form of the right letter matches.
|
||||
*/
|
||||
function referenceVectors(chars) {
|
||||
if (!references.has(chars)) {
|
||||
const forms = [chars, ZWJ + chars, chars + ZWJ, ZWJ + chars + ZWJ];
|
||||
references.set(
|
||||
chars,
|
||||
forms.map((text) => coverage(referenceInk(text, columnsOf.get(chars)))),
|
||||
);
|
||||
}
|
||||
return references.get(chars);
|
||||
}
|
||||
|
||||
function shapeScore(vector, chars) {
|
||||
return Math.max(...referenceVectors(chars).map((reference) => correlation(vector, reference)));
|
||||
}
|
||||
|
||||
/**
|
||||
* pango draws no ink for Apple Color Emoji, and draws flags on neither device. Those clusters have
|
||||
* no reference and are reported instead of judged.
|
||||
*/
|
||||
const hasReference = (chars) =>
|
||||
CELL_GLYPHS[chars] ||
|
||||
(!/^\p{Regional_Indicator}{2}$/u.test(chars) && referenceVectors(chars).some(Boolean));
|
||||
const judged = printed.filter((slot) => hasReference(slot.chars));
|
||||
const unjudged = printed
|
||||
.filter((slot) => !hasReference(slot.chars))
|
||||
.map((slot) => ({
|
||||
line: slot.line,
|
||||
col: slot.col,
|
||||
chars: slot.chars,
|
||||
reason: "no reference ink",
|
||||
}));
|
||||
|
||||
const distinct = [...new Set(judged.map((slot) => slot.chars))];
|
||||
const shapes = distinct.filter((chars) => !CELL_GLYPHS[chars]);
|
||||
|
||||
/** The cluster a slot is judged against: its own, or under `swap` a different one. */
|
||||
function expectedChars(slot, index) {
|
||||
if (control !== "swap") return slot.chars;
|
||||
const own = distinct.indexOf(slot.chars);
|
||||
const other = distinct[(own + 1 + (index % (distinct.length - 1))) % distinct.length];
|
||||
return other === slot.chars ? distinct[(own + 1) % distinct.length] : other;
|
||||
}
|
||||
|
||||
function judge(slot, index) {
|
||||
const expected = expectedChars(slot, index);
|
||||
const result = { line: slot.line, row: slot.row, col: slot.col, chars: slot.chars, expected };
|
||||
const spec = CELL_GLYPHS[expected];
|
||||
if (spec) {
|
||||
result.score = cellGlyphScore(slot, spec);
|
||||
return result;
|
||||
}
|
||||
// The shape must also resemble its own cluster at least as much as any other cluster in the
|
||||
// corpus, within MARGIN, so a near-lookalike such as g judged as 8 fails.
|
||||
const vector = coverage(screenshotInk(slot.box));
|
||||
const score = shapeScore(vector, expected);
|
||||
let nearest = { chars: expected, score };
|
||||
for (const other of shapes) {
|
||||
if (other === expected) continue;
|
||||
const otherScore = shapeScore(vector, other);
|
||||
if (otherScore > nearest.score) nearest = { chars: other, score: otherScore };
|
||||
}
|
||||
const distinctive = nearest.chars === expected || nearest.score - score <= MARGIN;
|
||||
result.score = distinctive ? score : Math.min(score, 0);
|
||||
result.nearest = nearest;
|
||||
return result;
|
||||
}
|
||||
|
||||
const results = judged.map(judge);
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
|
||||
const passed = results.filter((result) => result.score >= THRESHOLD);
|
||||
const failed = results.filter((result) => result.score < THRESHOLD);
|
||||
const outDir = resolve(dir, "identity");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(outDir, `${control}.json`),
|
||||
`${JSON.stringify({ control, threshold: THRESHOLD, unjudged, results }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const format = (result) =>
|
||||
`${result.line}@${result.col}(${result.chars}${result.expected === result.chars ? "" : `→${result.expected}`})=${result.score.toFixed(2)}${result.nearest && result.nearest.chars !== result.expected ? ` nearest ${result.nearest.chars}` : ""}`;
|
||||
console.log(
|
||||
`${report.label} ${control}: ${results.length} clusters judged, ${passed.length} pass, ${failed.length} fail, ${unjudged.length} unjudged (${outDir})`,
|
||||
);
|
||||
if (control === "none") {
|
||||
for (const result of failed) console.log(` fail ${format(result)}`);
|
||||
process.exitCode = failed.length === 0 ? 0 : 1;
|
||||
} else {
|
||||
for (const result of passed) console.log(` accepted ${format(result)}`);
|
||||
process.exitCode = passed.length === 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { PNG } from "pngjs";
|
||||
import { countInk, measureGrid } from "./check.mjs";
|
||||
import { RULER_CELLS } from "./fixture.mjs";
|
||||
|
||||
// Checks ink that leaves its row: stacked marks above and below a row reach into the rows next to
|
||||
// it. The script prints such rows, then in one update writes into two rows that hold another row's
|
||||
// overhang and erases two rows whose overhang reaches a blank row. The overhang must be drawn
|
||||
// before the update, stay drawn in the written rows, and leave no ink behind the erased rows.
|
||||
//
|
||||
// Preconditions are the same as `render.mjs`: the app runs the build being verified against a
|
||||
// daemon on the same host as this script, and shows an idle, focused terminal with an empty prompt.
|
||||
//
|
||||
// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/overhang.mjs
|
||||
// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/overhang.mjs
|
||||
const platform = process.env.PLATFORM ?? "android";
|
||||
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
|
||||
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "overhang";
|
||||
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
|
||||
const artifacts = resolve(
|
||||
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
|
||||
label,
|
||||
);
|
||||
const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir();
|
||||
const screenshotArgs =
|
||||
platform === "ios"
|
||||
? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"]
|
||||
: [];
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR:
|
||||
process.env.AGENT_DEVICE_STATE_DIR ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
|
||||
};
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
/** Marks stacked below the row: Arabic dots, rings below `g`, a vowel sign below a consonant. */
|
||||
const BELOW = "پپپ ييي ججج g̥̥̥̥ कृ";
|
||||
/** Marks stacked above the row: accents over `e` and `s`, candrabindu, om. */
|
||||
const ABOVE = "é́́́ ŝ̂̂ कँ ॐ";
|
||||
/** The marks rows stay left of this column; the update writes at it, outside the checked boxes. */
|
||||
const CHECKED_COLUMNS = 26;
|
||||
const WRITE_COLUMN = 30;
|
||||
/** 0-based rows. Each source row's overhang reaches the row next to it. */
|
||||
const SOURCES = [
|
||||
{ row: 2, text: BELOW, overhangRow: 3, update: "write" },
|
||||
{ row: 6, text: ABOVE, overhangRow: 5, update: "erase" },
|
||||
{ row: 9, text: BELOW, overhangRow: 10, update: "erase" },
|
||||
{ row: 13, text: ABOVE, overhangRow: 12, update: "write" },
|
||||
];
|
||||
const PARK_ROW = 16;
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function deviceArgs() {
|
||||
if (platform === "android") {
|
||||
return [
|
||||
"--platform",
|
||||
"android",
|
||||
...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
"--platform",
|
||||
"ios",
|
||||
...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function snapshotNodes() {
|
||||
return JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
}
|
||||
|
||||
function pressControl(id) {
|
||||
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
|
||||
(candidate) => candidate.identifier === id,
|
||||
);
|
||||
assert.ok(node?.rect, `No ${id} control on screen`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
|
||||
}
|
||||
|
||||
async function pressEnter() {
|
||||
pressControl("terminal-key-enter");
|
||||
await setTimeout(2_000);
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
ad("type", command, "--delay-ms", "0");
|
||||
await pressEnter();
|
||||
}
|
||||
|
||||
async function terminalSize() {
|
||||
await runCommand("clear; stty size");
|
||||
for (const node of snapshotNodes()) {
|
||||
const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim());
|
||||
if (match && (node.identifier ?? "").startsWith("terminal-row-")) {
|
||||
return { rows: Number(match[1]), columns: Number(match[2]) };
|
||||
}
|
||||
}
|
||||
throw new Error("No terminal row showed the `stty size` output");
|
||||
}
|
||||
|
||||
function shellQuote(text) {
|
||||
return `'${text.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Prints the source rows, waits for Enter, applies the update, and waits for Enter again. */
|
||||
function fixtureScript() {
|
||||
const at = (row, col) => `\\033[${row + 1};${col + 1}H`;
|
||||
const print = (row, text) => `printf '${at(row, 0)}%s' ${shellQuote(text)}`;
|
||||
const update = SOURCES.map((source) =>
|
||||
source.update === "write"
|
||||
? `${at(source.overhangRow, WRITE_COLUMN)}x`
|
||||
: `${at(source.row, 0)}\\033[2K`,
|
||||
).join("");
|
||||
return [
|
||||
"stty -echo",
|
||||
"printf '\\033[2J\\033[3J'",
|
||||
print(0, "█".repeat(RULER_CELLS)),
|
||||
...SOURCES.map((source) => print(source.row, source.text)),
|
||||
`printf '${at(PARK_ROW, 0)}'`,
|
||||
"read -r _",
|
||||
`printf '${update}${at(PARK_ROW, 0)}'`,
|
||||
"read -r _",
|
||||
"stty echo",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function screenshot(name) {
|
||||
const path = resolve(artifacts, name);
|
||||
ad("screenshot", path, ...screenshotArgs);
|
||||
return PNG.sync.read(readFileSync(path));
|
||||
}
|
||||
|
||||
/** Pixels in `box` where the two screenshots differ by more than the ink threshold. */
|
||||
function changedPixels(before, after, box) {
|
||||
const difference = new PNG({ width: before.width, height: before.height });
|
||||
for (let index = 0; index < before.data.length; index += 1) {
|
||||
difference.data[index] = Math.abs(before.data[index] - after.data[index]);
|
||||
}
|
||||
return countInk(difference, box, [0, 0, 0]);
|
||||
}
|
||||
|
||||
ad("open", appId, ...deviceArgs());
|
||||
const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes;
|
||||
const hasControl = (id) => controls.some((node) => node.identifier === id);
|
||||
assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first");
|
||||
if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle");
|
||||
|
||||
const size = await terminalSize();
|
||||
assert.ok(
|
||||
size.rows > PARK_ROW && size.columns > WRITE_COLUMN,
|
||||
`Terminal too small: ${JSON.stringify(size)}`,
|
||||
);
|
||||
const scriptPath = resolve(fixtureDir, "paseo-terminal-overhang.sh");
|
||||
writeFileSync(scriptPath, fixtureScript());
|
||||
await runCommand(`sh ${scriptPath}`);
|
||||
|
||||
const nodes = snapshotNodes();
|
||||
const before = screenshot("before.png");
|
||||
await pressEnter();
|
||||
const after = screenshot("after.png");
|
||||
await pressEnter();
|
||||
|
||||
// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels.
|
||||
const scale = before.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0));
|
||||
const ruler = nodes.find((node) => node.identifier === "terminal-row-0")?.rect;
|
||||
assert.ok(ruler, "The ruler row is not exposed as terminal-row-0");
|
||||
const rulerRect = {
|
||||
x: ruler.x * scale,
|
||||
y: ruler.y * scale,
|
||||
width: ruler.width * scale,
|
||||
height: ruler.height * scale,
|
||||
};
|
||||
const grid = measureGrid(before, rulerRect);
|
||||
const rowBox = (row) => ({
|
||||
left: grid.left,
|
||||
right: grid.left + CHECKED_COLUMNS * grid.cellWidth,
|
||||
top: rulerRect.y + row * rulerRect.height,
|
||||
bottom: rulerRect.y + (row + 1) * rulerRect.height,
|
||||
});
|
||||
|
||||
/** Measures one source row's overhang before and after the update. */
|
||||
function measure(source) {
|
||||
const box = rowBox(source.overhangRow);
|
||||
const overhangBefore = countInk(before, box, grid.background);
|
||||
if (source.update === "write") {
|
||||
const changedAfterWrite = changedPixels(before, after, box);
|
||||
const failures = [
|
||||
...(overhangBefore === 0 ? ["overhang-not-drawn"] : []),
|
||||
...(changedAfterWrite > 0 ? ["overhang-lost-after-write"] : []),
|
||||
];
|
||||
return { ...source, overhangBefore, changedAfterWrite, failures };
|
||||
}
|
||||
const inkAfterErase = countInk(after, box, grid.background);
|
||||
const sourceInkAfterErase = countInk(after, rowBox(source.row), grid.background);
|
||||
const failures = [
|
||||
...(overhangBefore === 0 ? ["overhang-not-drawn"] : []),
|
||||
...(inkAfterErase > 0 ? ["overhang-left-after-erase"] : []),
|
||||
...(sourceInkAfterErase > 0 ? ["source-left-after-erase"] : []),
|
||||
];
|
||||
return { ...source, overhangBefore, inkAfterErase, sourceInkAfterErase, failures };
|
||||
}
|
||||
|
||||
const results = SOURCES.map(measure);
|
||||
|
||||
const report = { platform, label, size, grid, rulerRect, results };
|
||||
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
const failures = results.flatMap((result) =>
|
||||
result.failures.map((failure) => `row ${result.overhangRow}: ${failure}`),
|
||||
);
|
||||
console.log(`${label}: ${failures.length} failures (${artifacts})`);
|
||||
for (const result of results) {
|
||||
console.log(
|
||||
` row ${result.row} -> ${result.overhangRow} (${result.update}): overhang ${result.overhangBefore}` +
|
||||
(result.update === "write"
|
||||
? `, changed after write ${result.changedAfterWrite}`
|
||||
: `, left after erase ${result.inkAfterErase}, source left ${result.sourceInkAfterErase}`),
|
||||
);
|
||||
}
|
||||
for (const failure of failures) console.log(` ${failure}`);
|
||||
process.exitCode = failures.length === 0 ? 0 : 1;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { homedir } from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
// Reattaches to a terminal whose PTY has a different size than the app will measure, and checks
|
||||
// that the client rows equal the daemon's rows. RESIZE=keyboard opens the keyboard, and the
|
||||
// relaunched app starts with it closed. RESIZE=fold folds an Android foldable emulator and
|
||||
// relaunches the app unfolded. Either way the shell redraws its prompt on attach.
|
||||
//
|
||||
// Preconditions: the app shows terminal TERMINAL_ID, idle at an empty shell prompt, against the
|
||||
// daemon at PASEO_HOST.
|
||||
//
|
||||
// PLATFORM=android TERMINAL_ID=511fd9f0 PASEO_HOST=localhost:6870 \
|
||||
// node e2e/mobile/terminal-rendering/reattach.mjs
|
||||
//
|
||||
// The client used to be sized after the replay arrived, so the shell's redraw landed on the
|
||||
// wrong rows in most trials. TRIALS sets how many reattaches to check.
|
||||
const platform = process.env.PLATFORM ?? "android";
|
||||
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
|
||||
const appUrl = process.env.PASEO_TERMINAL_RENDERING_APP_URL;
|
||||
const terminalId = process.env.TERMINAL_ID;
|
||||
const host = process.env.PASEO_HOST;
|
||||
const trials = Number(process.env.TRIALS ?? 5);
|
||||
const resize = process.env.RESIZE ?? "keyboard";
|
||||
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "reattach";
|
||||
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
|
||||
const artifacts = resolve(
|
||||
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
|
||||
label,
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR:
|
||||
process.env.AGENT_DEVICE_STATE_DIR ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
|
||||
};
|
||||
if (!terminalId || !host) throw new Error("Set TERMINAL_ID and PASEO_HOST");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
function paseo(...args) {
|
||||
return execFileSync("npm", ["run", "-s", "cli", "--", ...args, "--host", host], {
|
||||
cwd: resolve(import.meta.dirname, "../../../../.."),
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function pressControl(id) {
|
||||
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
|
||||
(candidate) => candidate.identifier === id,
|
||||
);
|
||||
if (!node?.rect) throw new Error(`No ${id} control on screen`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
|
||||
}
|
||||
|
||||
async function resizePty() {
|
||||
if (resize === "keyboard") {
|
||||
pressControl("terminal-keyboard-toggle");
|
||||
await setTimeout(2_000);
|
||||
return;
|
||||
}
|
||||
const adb = (...args) =>
|
||||
execFileSync("adb", [
|
||||
...(process.env.ANDROID_SERIAL ? ["-s", process.env.ANDROID_SERIAL] : []),
|
||||
...args,
|
||||
]);
|
||||
adb("emu", "fold");
|
||||
await setTimeout(5_000);
|
||||
adb("shell", "am", "force-stop", appId);
|
||||
adb("emu", "unfold");
|
||||
await setTimeout(3_000);
|
||||
}
|
||||
|
||||
function clientRows() {
|
||||
const nodes = JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
const byIndex = new Map(nodes.map((node) => [node.index, node]));
|
||||
const rows = [];
|
||||
for (const node of nodes) {
|
||||
const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? "");
|
||||
if (!match) continue;
|
||||
// agent-device leaves the label on the parent when the row inherits it.
|
||||
const text = node.label ?? (node.inheritsLabel ? byIndex.get(node.parentIndex)?.label : "");
|
||||
rows[Number(match[1])] = (text ?? "").trim();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the relaunched app to show terminal rows, then for the shell's redraw to land.
|
||||
* Returns null when no row appears, for example when the app crashed on launch.
|
||||
*/
|
||||
async function attachedClientRows() {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await setTimeout(2_000);
|
||||
if (clientRows().some((text) => text !== "")) {
|
||||
await setTimeout(3_000);
|
||||
return clientRows();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mismatches(client, server) {
|
||||
const rows = [];
|
||||
for (let row = 0; row < Math.max(client.length, server.length); row += 1) {
|
||||
const expected = (server[row] ?? "").trim();
|
||||
const actual = client[row] ?? "";
|
||||
if (expected !== actual) rows.push({ row, server: expected, client: actual });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let trial = 1; trial <= trials; trial += 1) {
|
||||
paseo("terminal", "send-keys", terminalId, "clear", "Enter");
|
||||
await setTimeout(1_500);
|
||||
await resizePty();
|
||||
ad("open", appId, ...(appUrl ? [appUrl] : []), "--relaunch");
|
||||
const client = await attachedClientRows();
|
||||
ad("screenshot", resolve(artifacts, `trial-${trial}.png`));
|
||||
if (!client) {
|
||||
results.push({ trial, attached: false });
|
||||
console.log(`${label} trial ${trial}: no terminal rows appeared`);
|
||||
continue;
|
||||
}
|
||||
const server = paseo("terminal", "capture", terminalId).split("\n");
|
||||
const rows = mismatches(client, server);
|
||||
results.push({ trial, attached: true, mismatchedRows: rows });
|
||||
console.log(`${label} trial ${trial}: ${rows.length} mismatched rows`);
|
||||
}
|
||||
|
||||
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(results, null, 2)}\n`);
|
||||
const attached = results.filter((result) => result.attached);
|
||||
const failed = attached.filter((result) => result.mismatchedRows.length > 0).length;
|
||||
console.log(
|
||||
`${label}: ${failed} of ${attached.length} reattaches differ from the daemon, ${trials - attached.length} never attached (${artifacts})`,
|
||||
);
|
||||
process.exitCode = failed === 0 && attached.length === trials ? 0 : 1;
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { PNG } from "pngjs";
|
||||
import {
|
||||
checkPartialSelection,
|
||||
checkTerminalScreenshot,
|
||||
selectionTarget,
|
||||
writeOverlay,
|
||||
} from "./check.mjs";
|
||||
import { fixtureOutput } from "./fixture.mjs";
|
||||
|
||||
// Renders the fixture in a real Paseo terminal and checks the screenshot with `check.mjs`, then
|
||||
// selects one cell of a conjunct and checks a second screenshot.
|
||||
//
|
||||
// Preconditions: the app runs the build being verified against a daemon on the same host as
|
||||
// this script, and shows an idle, focused terminal with an empty shell prompt.
|
||||
//
|
||||
// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs
|
||||
// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs
|
||||
const platform = process.env.PLATFORM ?? "android";
|
||||
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
|
||||
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "run";
|
||||
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
|
||||
const artifacts = resolve(
|
||||
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
|
||||
label,
|
||||
);
|
||||
const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir();
|
||||
// iOS simulators screenshot at one pixel per point unless asked; glyph identity needs the
|
||||
// device's real density. Android screenshots are always in device pixels.
|
||||
const screenshotArgs =
|
||||
platform === "ios"
|
||||
? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"]
|
||||
: [];
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR:
|
||||
process.env.AGENT_DEVICE_STATE_DIR ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
|
||||
};
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function deviceArgs() {
|
||||
if (platform === "android") {
|
||||
return [
|
||||
"--platform",
|
||||
"android",
|
||||
...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
"--platform",
|
||||
"ios",
|
||||
...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function snapshotNodes() {
|
||||
return JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
}
|
||||
|
||||
function rowNodes(nodes) {
|
||||
const rows = new Map();
|
||||
for (const node of nodes) {
|
||||
const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? "");
|
||||
if (match && node.rect) rows.set(Number(match[1]), node);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Taps a control at its center. On iOS the keyboard toolbar is a full-screen accessibility node
|
||||
* listed after the key bar, so pressing by selector reports the keys as covered.
|
||||
*/
|
||||
function pressControl(id) {
|
||||
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
|
||||
(candidate) => candidate.identifier === id,
|
||||
);
|
||||
assert.ok(node?.rect, `No ${id} control on screen`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
ad("type", command, "--delay-ms", "0");
|
||||
pressControl("terminal-key-enter");
|
||||
await setTimeout(2_000);
|
||||
}
|
||||
|
||||
async function terminalSize() {
|
||||
await runCommand("clear; stty size");
|
||||
for (const node of snapshotNodes()) {
|
||||
const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim());
|
||||
if (match && (node.identifier ?? "").startsWith("terminal-row-")) {
|
||||
return { rows: Number(match[1]), columns: Number(match[2]) };
|
||||
}
|
||||
}
|
||||
throw new Error("No terminal row showed the `stty size` output");
|
||||
}
|
||||
|
||||
ad("open", appId, ...deviceArgs());
|
||||
const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes;
|
||||
const hasControl = (id) => controls.some((node) => node.identifier === id);
|
||||
assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first");
|
||||
if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle");
|
||||
|
||||
const size = await terminalSize();
|
||||
const fixturePath = resolve(fixtureDir, "paseo-terminal-rendering.txt");
|
||||
const scriptPath = resolve(fixtureDir, "paseo-terminal-rendering.sh");
|
||||
writeFileSync(fixturePath, fixtureOutput(size.columns));
|
||||
writeFileSync(scriptPath, `printf '\\033[2J\\033[3J\\033[H'; cat '${fixturePath}'; sleep 120\n`);
|
||||
await runCommand(`sh ${scriptPath}`);
|
||||
|
||||
const nodes = snapshotNodes();
|
||||
const screenshotPath = resolve(artifacts, "terminal.png");
|
||||
ad("screenshot", screenshotPath, ...screenshotArgs);
|
||||
const png = PNG.sync.read(readFileSync(screenshotPath));
|
||||
// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels.
|
||||
const scale = png.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0));
|
||||
const toPixels = ({ x, y, width, height }) => ({
|
||||
x: x * scale,
|
||||
y: y * scale,
|
||||
width: width * scale,
|
||||
height: height * scale,
|
||||
});
|
||||
const rows = rowNodes(nodes);
|
||||
const rects = new Map([...rows].map(([index, node]) => [index, toPixels(node.rect)]));
|
||||
const { grid, failures, slots } = await checkTerminalScreenshot({ png, size, rows: rects });
|
||||
|
||||
// Long-press selects the conjunct as a word; dragging back from its second cell leaves the first.
|
||||
const target = await selectionTarget({ size });
|
||||
const targetRect = rects.get(target.row);
|
||||
assert.ok(targetRect, "The select line is not on screen");
|
||||
const y = Math.round((targetRect.y + targetRect.height / 2) / scale);
|
||||
const cellX = (offset) => Math.round((grid.left + (target.col + offset) * grid.cellWidth) / scale);
|
||||
ad("longpress", String(cellX(0.5)), String(y), "800");
|
||||
ad("gesture", "pan", String(cellX(1.75)), String(y), String(cellX(0.5) - cellX(1.75)), "0", "600");
|
||||
const selectionPath = resolve(artifacts, "selection.png");
|
||||
ad("screenshot", selectionPath, ...screenshotArgs);
|
||||
const selectionFailures = checkPartialSelection({
|
||||
png: PNG.sync.read(readFileSync(selectionPath)),
|
||||
rows: rects,
|
||||
grid,
|
||||
target,
|
||||
});
|
||||
for (const failure of selectionFailures) failures.push({ line: "select", ...failure });
|
||||
// A tap clears the selection; it would otherwise survive into the next run's screenshot.
|
||||
const lastRect = rects.get(size.rows - 1);
|
||||
if (lastRect) {
|
||||
ad("press", String(cellX(4)), String(Math.round((lastRect.y + lastRect.height / 2) / scale)));
|
||||
}
|
||||
try {
|
||||
pressControl("terminal-key-ctrl");
|
||||
ad("type", "c", "--delay-ms", "0");
|
||||
} catch (error) {
|
||||
console.warn(`Could not interrupt the fixture: ${error.message}`);
|
||||
}
|
||||
|
||||
writeOverlay(png, slots, resolve(artifacts, "overlay.png"));
|
||||
const report = {
|
||||
platform,
|
||||
label,
|
||||
size,
|
||||
grid,
|
||||
checkedClusters: slots.length,
|
||||
failures,
|
||||
rows: [...rows.keys()].map((index) => ({
|
||||
index,
|
||||
label: rows.get(index).label,
|
||||
rect: rects.get(index),
|
||||
})),
|
||||
};
|
||||
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
const byLine = Map.groupBy(failures, (failure) => failure.line);
|
||||
console.log(
|
||||
`${label}: ${slots.length} clusters checked, ${failures.length} failures (${artifacts})`,
|
||||
);
|
||||
for (const [line, list] of byLine) {
|
||||
console.log(
|
||||
` ${line}: ${list.map((failure) => `${failure.kind}@${failure.col ?? "-"}${failure.char ? `(${failure.char})` : ""}`).join(" ")}`,
|
||||
);
|
||||
}
|
||||
process.exitCode = failures.length === 0 ? 0 : 1;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { writeFile, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
@@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
|
||||
export function suspendDaemonProcess(pid: number): void {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
}
|
||||
|
||||
export function resumeDaemonProcess(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, "SIGCONT");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
|
||||
try {
|
||||
const state = await psField(pid, "stat");
|
||||
return state.length > 0 && !state.startsWith("Z");
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown }).code === 1) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
|
||||
const directory = path.join(daemon.paseoHome, "terminal-worker");
|
||||
const records = await Promise.all(
|
||||
(await readdir(directory))
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => {
|
||||
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
|
||||
pid: number;
|
||||
terminals: Array<{ pid: number }>;
|
||||
};
|
||||
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
|
||||
}),
|
||||
);
|
||||
return records.flat();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
spawn,
|
||||
execFile,
|
||||
execFileSync,
|
||||
type ChildProcess,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -6,6 +13,8 @@ import path from "node:path";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
@@ -152,6 +161,24 @@ export async function startIsolatedHostDaemon(
|
||||
const serverDir = publishedPackageRoot
|
||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||
: path.resolve(__dirname, "../../../../server");
|
||||
let child: ChildProcess | null = null;
|
||||
const stopDaemon = async (): Promise<void> => {
|
||||
// The CLI owns graceful shutdown, forced cleanup and detached terminal
|
||||
// reaping. Keep its ESM graph out of Playwright's transforming loader.
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, "../../../../cli/dist/index.js"),
|
||||
"daemon",
|
||||
"stop",
|
||||
"--home",
|
||||
paseoHome,
|
||||
"--force",
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await killProcessTree(child);
|
||||
};
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: serverDir,
|
||||
@@ -169,21 +196,21 @@ export async function startIsolatedHostDaemon(
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
};
|
||||
const child = publishedPackageRoot
|
||||
const spawned = publishedPackageRoot
|
||||
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
|
||||
|
||||
child = spawned;
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
spawned.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
await waitForServer(port, spawned);
|
||||
return spawned;
|
||||
} catch (error) {
|
||||
await killProcessTree(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -191,10 +218,10 @@ export async function startIsolatedHostDaemon(
|
||||
}
|
||||
};
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = await spawnDaemon();
|
||||
await spawnDaemon();
|
||||
} catch (error) {
|
||||
await stopDaemon();
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -209,22 +236,22 @@ export async function startIsolatedHostDaemon(
|
||||
serverId,
|
||||
port,
|
||||
paseoHome,
|
||||
getPid: () => child.pid,
|
||||
getPid: () => child?.pid,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await killProcessTree(child);
|
||||
child = await spawnDaemon();
|
||||
await stopDaemon();
|
||||
await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await killProcessTree(child);
|
||||
await stopDaemon();
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
if (publishedPackageRoot) {
|
||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||
}
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ interface SeedProjectDescriptor {
|
||||
* prefer those wrappers over reaching for this client directly.
|
||||
*/
|
||||
export interface SeedDaemonClient {
|
||||
patchDaemonConfig(
|
||||
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
|
||||
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
|
||||
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
addProject(cwd: string): Promise<{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
@@ -206,3 +207,100 @@ export function computePercentile(samples: number[], p: number): number {
|
||||
export function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** Observe actual cursor pixels and blink phases through xterm's public render event. */
|
||||
export async function expectTerminalCursor(
|
||||
page: Page,
|
||||
expected: { shape: "bar" | "underline"; blinking: boolean },
|
||||
): Promise<void> {
|
||||
const observed = await page.evaluate(async () => {
|
||||
const terminal = (window as Window & { __paseoTerminal?: Terminal }).__paseoTerminal;
|
||||
if (!terminal) throw new Error("Terminal did not mount");
|
||||
const source = Array.from(document.querySelectorAll(".xterm-screen canvas")).find(
|
||||
(canvas): canvas is HTMLCanvasElement =>
|
||||
canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2") !== null,
|
||||
);
|
||||
if (!source) throw new Error("Cursor verification requires the actual WebGL renderer");
|
||||
const copy = document.createElement("canvas");
|
||||
const context = copy.getContext("2d");
|
||||
if (!context) throw new Error("Cursor pixel capture is unavailable");
|
||||
terminal.focus();
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
if (!terminal.element?.contains(document.activeElement))
|
||||
throw new Error("Terminal did not focus");
|
||||
const started = performance.now();
|
||||
const frames: Array<{ time: number; width: number; height: number }> = [];
|
||||
const rendered = terminal.onRender(() => {
|
||||
copy.width = source.width;
|
||||
copy.height = source.height;
|
||||
// WebGL's default drawing buffer must be copied before this render returns.
|
||||
context.drawImage(source, 0, 0);
|
||||
const cellWidth = source.width / terminal.cols;
|
||||
const cellHeight = source.height / terminal.rows;
|
||||
const x = Math.round(terminal.buffer.active.cursorX * cellWidth);
|
||||
const y = Math.round(terminal.buffer.active.cursorY * cellHeight);
|
||||
const width = Math.ceil(cellWidth);
|
||||
const height = Math.ceil(cellHeight);
|
||||
const background = context.getImageData(x + width + 2, y, 1, 1).data;
|
||||
const pixels = context.getImageData(x, y, width, height).data;
|
||||
let minX = width;
|
||||
let maxX = -1;
|
||||
let minY = height;
|
||||
let maxY = -1;
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
const offset = (row * width + col) * 4;
|
||||
if (
|
||||
![0, 1, 2, 3].some(
|
||||
(channel) => Math.abs(pixels[offset + channel]! - background[channel]!) > 20,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
minX = Math.min(minX, col);
|
||||
maxX = Math.max(maxX, col);
|
||||
minY = Math.min(minY, row);
|
||||
maxY = Math.max(maxY, row);
|
||||
}
|
||||
}
|
||||
frames.push({
|
||||
time: performance.now() - started,
|
||||
width: maxX < 0 ? 0 : (maxX - minX + 1) / cellWidth,
|
||||
height: maxY < 0 ? 0 : (maxY - minY + 1) / cellHeight,
|
||||
});
|
||||
});
|
||||
try {
|
||||
const refresh = setInterval(() => terminal.refresh(0, terminal.rows - 1), 40);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
} finally {
|
||||
clearInterval(refresh);
|
||||
}
|
||||
} finally {
|
||||
rendered.dispose();
|
||||
}
|
||||
return {
|
||||
frames,
|
||||
focused: terminal.element?.contains(document.activeElement),
|
||||
samples: frames.length,
|
||||
width: Math.max(...frames.map((frame) => frame.width)),
|
||||
height: Math.max(...frames.map((frame) => frame.height)),
|
||||
blinking:
|
||||
frames.filter(
|
||||
(frame, index) => index > 0 && (frame.width === 0) !== (frames[index - 1]!.width === 0),
|
||||
).length >= 2,
|
||||
};
|
||||
});
|
||||
expect(observed.samples).toBeGreaterThan(5);
|
||||
expect(observed.blinking, JSON.stringify(observed)).toBe(expected.blinking);
|
||||
if (expected.shape === "bar") {
|
||||
expect(observed.width).toBeGreaterThan(0);
|
||||
expect(observed.width).toBeLessThan(0.4);
|
||||
expect(observed.height).toBeGreaterThan(0.6);
|
||||
} else {
|
||||
expect(observed.width).toBeGreaterThan(0.6);
|
||||
expect(observed.height).toBeGreaterThan(0);
|
||||
expect(observed.height).toBeLessThan(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface UsageReportsFixtureOptions {
|
||||
* app connects, so a reload can switch it.
|
||||
*/
|
||||
usageSupported?: boolean | (() => boolean);
|
||||
usageSettingSupported?: boolean;
|
||||
/** Released hosts expose provider.usage.list with no source icons. */
|
||||
providerUsageListOnly?: boolean;
|
||||
/** The host daemon's port; defaults to the E2E daemon. */
|
||||
@@ -63,6 +64,7 @@ function withUsageSupportFeature(
|
||||
message: WebSocketMessage,
|
||||
enabled: boolean,
|
||||
providerUsageListOnly: boolean,
|
||||
usageSettingSupported: boolean,
|
||||
): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
@@ -86,6 +88,7 @@ function withUsageSupportFeature(
|
||||
...payload,
|
||||
features: {
|
||||
...features,
|
||||
usageSetting: usageSettingSupported ? true : undefined,
|
||||
usageSources: enabled && !providerUsageListOnly,
|
||||
providerUsageList: enabled,
|
||||
},
|
||||
@@ -229,7 +232,12 @@ export async function installUsageReportsFixture(
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
|
||||
? withUsageSupportFeature(
|
||||
message,
|
||||
isUsageSupported(),
|
||||
providerUsageListOnly,
|
||||
options.usageSettingSupported ?? true,
|
||||
)
|
||||
: null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,25 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'sh.paseo.terminal'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
useDefaultAndroidSdkVersions()
|
||||
|
||||
android {
|
||||
namespace "sh.paseo.terminal"
|
||||
defaultConfig {
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.customview:customview:1.1.0"
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
androidTestImplementation "androidx.test:runner:1.6.2"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.2.1"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"output": "स्ते",
|
||||
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "क्ष",
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "নক্ষত্র",
|
||||
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
||||
"clusterStarts": [0, 1, 3, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "क्अ",
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्अ ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "ক্অ",
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;ক্অ ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "अ्क",
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
}
|
||||
]
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import android.os.Build
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
private const val COLUMNS = 8
|
||||
|
||||
/** Rasters that draw the same clusters match exactly; a wrong clustering differs by 0.12 or more. */
|
||||
private const val MATCH_TOLERANCE = 0.01f
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TerminalRowLayoutTest {
|
||||
private val font = TerminalFont("monospace", 40f, 1f)
|
||||
private val paints = TerminalPaints()
|
||||
|
||||
/**
|
||||
* Draws each row of `grapheme-rows.json` and compares it with its grapheme clusters drawn one at a
|
||||
* time, each centred in its own columns. `terminal-frame.test.ts` checks that the rows are what
|
||||
* xterm produces and that the clusters are UAX #29 grapheme clusters.
|
||||
*/
|
||||
@Test
|
||||
fun drawsEachGraphemeClusterInItsOwnColumns() {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.assets
|
||||
val rows = JSONArray(assets.open("grapheme-rows.json").bufferedReader().readText())
|
||||
for (index in 0 until rows.length()) {
|
||||
val row = rows.getJSONObject(index)
|
||||
val source = TerminalRowSource.decode(row.getString("row"))
|
||||
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
|
||||
// Selection covers row 0 only, so drawing as row 1 draws nothing selected.
|
||||
val drawn = raster { layout.drawSelected(it, 1, TerminalSelection(TerminalSelectionRecord()), paints) }
|
||||
val expected = raster { drawClusters(it, source, starts(row, "clusterStarts")) }
|
||||
val contrast = raster { drawClusters(it, source, starts(row, "contrastStarts")) }
|
||||
val output = row.getString("output")
|
||||
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
|
||||
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
|
||||
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
|
||||
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
|
||||
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
|
||||
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
|
||||
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
|
||||
continue
|
||||
}
|
||||
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
|
||||
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
|
||||
val red = 0xffff0000.toInt()
|
||||
val green = 0xff00aa00.toInt()
|
||||
val overlay = 0x472684ff
|
||||
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
|
||||
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
|
||||
paints.selectionBackground = overlay
|
||||
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||
val actual = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||
}
|
||||
val expected = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
canvas.drawColor(green)
|
||||
canvas.drawColor(overlay)
|
||||
}
|
||||
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
|
||||
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
|
||||
})
|
||||
}
|
||||
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
|
||||
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||
val actual = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||
}
|
||||
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
|
||||
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun starts(row: JSONObject, key: String): List<Int> =
|
||||
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
|
||||
|
||||
private fun raster(draw: (Canvas) -> Unit): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(COLUMNS * font.cellWidthPx, font.cellHeightPx, Bitmap.Config.ARGB_8888)
|
||||
draw(Canvas(bitmap))
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/** Draws each cluster as its own text, centred in its columns and scaled down when wider. */
|
||||
private fun drawClusters(canvas: Canvas, source: TerminalRowSource, starts: List<Int>) {
|
||||
val cellCount = source.cells.size / 2
|
||||
val paint = Paint(font.paint(0)).apply { color = source.styles[1].toLong().toInt() }
|
||||
val offsets = IntArray(cellCount + 1)
|
||||
val cols = IntArray(cellCount + 1)
|
||||
for (cell in 0 until cellCount) {
|
||||
offsets[cell + 1] = offsets[cell] + source.cells[cell * 2]
|
||||
cols[cell + 1] = cols[cell] + source.cells[cell * 2 + 1]
|
||||
}
|
||||
for ((index, first) in starts.withIndex()) {
|
||||
val end = starts.getOrElse(index + 1) { cellCount }
|
||||
val text = source.text.substring(offsets[first], offsets[end])
|
||||
if (text.isBlank()) continue
|
||||
val slot = (cols[end] - cols[first]) * font.cellWidthPx.toFloat()
|
||||
val advance = paint.measureText(text)
|
||||
val x = cols[first] * font.cellWidthPx + if (advance > slot + 1f) 0f else (slot - advance) / 2f
|
||||
canvas.save()
|
||||
if (advance > slot + 1f) canvas.scale(slot / advance, slot / advance, x, font.cellHeightPx / 2f)
|
||||
canvas.drawText(text, x, font.baselinePx.toFloat(), paint)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
/** Share of inked pixels, in either image, whose coverage differs by more than a quarter. */
|
||||
private fun difference(a: Bitmap, b: Bitmap): Float {
|
||||
var inked = 0
|
||||
var differing = 0
|
||||
for (y in 0 until a.height) {
|
||||
for (x in 0 until a.width) {
|
||||
val (left, right) = (a.getPixel(x, y) ushr 24) to (b.getPixel(x, y) ushr 24)
|
||||
if (left < 64 && right < 64) continue
|
||||
inked += 1
|
||||
if (kotlin.math.abs(left - right) > 64) differing += 1
|
||||
}
|
||||
}
|
||||
return if (inked == 0) 0f else differing.toFloat() / inked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class PaseoTerminalModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PaseoTerminal")
|
||||
|
||||
View(PaseoTerminalView::class) {
|
||||
Events("onCellMetrics")
|
||||
|
||||
Prop("frame") { view: PaseoTerminalView, frame: TerminalFrameRecord -> view.setFrame(frame) }
|
||||
Prop("theme") { view: PaseoTerminalView, theme: TerminalThemeRecord -> view.setTheme(theme) }
|
||||
Prop("fontFamily") { view: PaseoTerminalView, family: String -> view.setFontFamily(family) }
|
||||
Prop("fontSize") { view: PaseoTerminalView, size: Double -> view.setFontSize(size) }
|
||||
Prop("customGlyphs") { view: PaseoTerminalView, glyphs: List<TerminalCustomGlyphRecord> ->
|
||||
view.setCustomGlyphs(glyphs)
|
||||
}
|
||||
|
||||
OnViewDidUpdateProps { view: PaseoTerminalView -> view.commit() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
||||
import androidx.customview.widget.ExploreByTouchHelper
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
private const val MIN_CACHED_ROWS = 256
|
||||
private const val CURSOR_ALPHA = 0.45f
|
||||
|
||||
/**
|
||||
* Paints terminal frames on the cell grid. Row shaping is cached by row content, so cursor and
|
||||
* selection changes repaint without reshaping, and unchanged rows replay their display lists.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
private val onCellMetrics by EventDispatcher<Map<String, Float>>()
|
||||
|
||||
private var fontFamily = "monospace"
|
||||
private var fontSize = 12.0
|
||||
private var font: TerminalFont? = null
|
||||
private var customGlyphs = TerminalCustomGlyphs.EMPTY
|
||||
private var frame: TerminalFrameRecord? = null
|
||||
private var frameChanged = false
|
||||
|
||||
private var rows: List<TerminalRowLayout> = emptyList()
|
||||
private var columns = 0
|
||||
private var cursor: TerminalCursorRecord? = null
|
||||
private var selection: TerminalSelection? = null
|
||||
private var backgroundColor = Color.BLACK
|
||||
private var cursorColor = Color.WHITE
|
||||
private val paints = TerminalPaints()
|
||||
private val rowCache = object : LinkedHashMap<String, TerminalRowLayout>(64, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, TerminalRowLayout>): Boolean {
|
||||
if (size <= maxOf(MIN_CACHED_ROWS, rows.size * 2)) return false
|
||||
eldest.value.release()
|
||||
return true
|
||||
}
|
||||
}
|
||||
private val accessibility = RowAccessibility()
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
ViewCompat.setAccessibilityDelegate(this, accessibility)
|
||||
}
|
||||
|
||||
fun setFrame(value: TerminalFrameRecord) {
|
||||
frame = value
|
||||
frameChanged = true
|
||||
}
|
||||
|
||||
fun setTheme(value: TerminalThemeRecord) {
|
||||
backgroundColor = argb(value.background)
|
||||
cursorColor = argb(value.cursor)
|
||||
paints.selectionBackground = argb(value.selectionBackground)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setFontFamily(value: String) {
|
||||
fontFamily = value
|
||||
}
|
||||
|
||||
fun setFontSize(value: Double) {
|
||||
fontSize = value
|
||||
}
|
||||
|
||||
fun setCustomGlyphs(value: List<TerminalCustomGlyphRecord>) {
|
||||
customGlyphs = TerminalCustomGlyphs(value)
|
||||
clearRowCache()
|
||||
frameChanged = true
|
||||
}
|
||||
|
||||
/** Applies a batch of prop updates: font first, then the frame laid out with it. */
|
||||
fun commit() {
|
||||
val density = resources.displayMetrics.density
|
||||
val sizePx = (fontSize * density).toFloat()
|
||||
val current = font
|
||||
if (current == null || !current.sameAs(fontFamily, sizePx, density)) {
|
||||
val next = TerminalFont(fontFamily, sizePx, density)
|
||||
font = next
|
||||
clearRowCache()
|
||||
frameChanged = true
|
||||
onCellMetrics(
|
||||
mapOf(
|
||||
"cellWidth" to next.cellWidthPx / density,
|
||||
"cellHeight" to next.cellHeightPx / density,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (frameChanged) {
|
||||
frameChanged = false
|
||||
layoutFrame()
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun layoutFrame() {
|
||||
val currentFont = font ?: return
|
||||
val record = frame ?: return
|
||||
columns = record.columns
|
||||
cursor = record.cursor
|
||||
selection = record.selection?.let(::TerminalSelection)
|
||||
rows = record.rows.map { encoded ->
|
||||
rowCache.getOrPut(encoded) {
|
||||
TerminalRowLayout(TerminalRowSource.decode(encoded), currentFont, customGlyphs)
|
||||
}
|
||||
}
|
||||
accessibility.invalidateRoot()
|
||||
}
|
||||
|
||||
private fun clearRowCache() {
|
||||
for (layout in rowCache.values) layout.release()
|
||||
rowCache.clear()
|
||||
rows = emptyList()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
canvas.drawColor(backgroundColor)
|
||||
val currentFont = font ?: return
|
||||
val cellWidth = currentFont.cellWidthPx
|
||||
val cellHeight = currentFont.cellHeightPx
|
||||
val gridWidth = columns * cellWidth
|
||||
canvas.save()
|
||||
canvas.clipRect(0, 0, minOf(width, gridWidth), height)
|
||||
val activeSelection = selection
|
||||
for ((index, row) in rows.withIndex()) {
|
||||
canvas.save()
|
||||
canvas.translate(0f, (index * cellHeight).toFloat())
|
||||
if (activeSelection != null && activeSelection.intersectsRow(index)) {
|
||||
row.drawSelected(canvas, index, activeSelection, paints)
|
||||
} else {
|
||||
row.draw(canvas, gridWidth, paints)
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
cursor?.let { position ->
|
||||
paints.fill.color = withAlpha(cursorColor, CURSOR_ALPHA)
|
||||
val left = (position.col * cellWidth).toFloat()
|
||||
val top = (position.row * cellHeight).toFloat()
|
||||
canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paints.fill)
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
override fun dispatchHoverEvent(event: MotionEvent): Boolean =
|
||||
accessibility.dispatchHoverEvent(event) || super.dispatchHoverEvent(event)
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
for (layout in rowCache.values) layout.release()
|
||||
}
|
||||
|
||||
private fun withAlpha(color: Int, alpha: Float): Int =
|
||||
Color.argb((Color.alpha(color) * alpha).toInt(), Color.red(color), Color.green(color), Color.blue(color))
|
||||
|
||||
/** Exposes each non-blank row as a virtual text node, `terminal-row-<index>`. */
|
||||
private inner class RowAccessibility : ExploreByTouchHelper(this@PaseoTerminalView) {
|
||||
override fun getVirtualViewAt(x: Float, y: Float): Int {
|
||||
val cellHeight = font?.cellHeightPx ?: return INVALID_ID
|
||||
val row = (y / cellHeight).toInt()
|
||||
return if (row in rows.indices && rows[row].describe().isNotEmpty()) row else INVALID_ID
|
||||
}
|
||||
|
||||
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
|
||||
for ((index, row) in rows.withIndex()) {
|
||||
if (row.describe().isNotEmpty()) virtualViewIds.add(index)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
|
||||
val cellHeight = font?.cellHeightPx ?: 1
|
||||
val label = rows.getOrNull(virtualViewId)?.describe().orEmpty()
|
||||
node.className = TextView::class.java.name
|
||||
node.text = label
|
||||
node.contentDescription = label
|
||||
node.viewIdResourceName = "terminal-row-$virtualViewId"
|
||||
val top = virtualViewId * cellHeight
|
||||
node.setBoundsInParent(Rect(0, top, minOf(width, columns * (font?.cellWidthPx ?: 1)), top + cellHeight))
|
||||
}
|
||||
|
||||
override fun onPerformActionForVirtualView(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean = false
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
|
||||
/**
|
||||
* Box drawing and block elements painted as cell geometry instead of font glyphs, so frames and
|
||||
* blocks join across cells regardless of the font's own metrics.
|
||||
*/
|
||||
internal class TerminalCustomGlyph(record: TerminalCustomGlyphRecord) {
|
||||
private val rects: FloatArray = FloatArray(record.rects.size) { record.rects[it].toFloat() }
|
||||
private val commands: DoubleArray = record.path
|
||||
private val strokeWidth: Float = record.strokeWidth.toFloat()
|
||||
private var scaledPath: Path? = null
|
||||
private var scaledWidth = 0f
|
||||
private var scaledHeight = 0f
|
||||
|
||||
fun draw(canvas: Canvas, x: Float, width: Float, height: Float, density: Float, paint: Paint) {
|
||||
paint.style = Paint.Style.FILL
|
||||
var index = 0
|
||||
while (index + 3 < rects.size) {
|
||||
canvas.drawRect(
|
||||
x + rects[index] * width,
|
||||
rects[index + 1] * height,
|
||||
x + (rects[index] + rects[index + 2]) * width,
|
||||
(rects[index + 1] + rects[index + 3]) * height,
|
||||
paint,
|
||||
)
|
||||
index += 4
|
||||
}
|
||||
if (commands.isEmpty()) return
|
||||
val path = pathFor(width, height)
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = strokeWidth * density
|
||||
paint.strokeCap = Paint.Cap.BUTT
|
||||
paint.strokeJoin = Paint.Join.MITER
|
||||
canvas.save()
|
||||
canvas.translate(x, 0f)
|
||||
canvas.drawPath(path, paint)
|
||||
canvas.restore()
|
||||
paint.style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private fun pathFor(width: Float, height: Float): Path {
|
||||
val cached = scaledPath
|
||||
if (cached != null && scaledWidth == width && scaledHeight == height) return cached
|
||||
val path = Path()
|
||||
var index = 0
|
||||
while (index < commands.size) {
|
||||
val x = { offset: Int -> commands[index + offset].toFloat() * width }
|
||||
val y = { offset: Int -> commands[index + offset].toFloat() * height }
|
||||
when (commands[index].toInt()) {
|
||||
0 -> { path.moveTo(x(1), y(2)); index += 3 }
|
||||
1 -> { path.lineTo(x(1), y(2)); index += 3 }
|
||||
2 -> { path.quadTo(x(1), y(2), x(3), y(4)); index += 5 }
|
||||
else -> index = commands.size
|
||||
}
|
||||
}
|
||||
scaledPath = path
|
||||
scaledWidth = width
|
||||
scaledHeight = height
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
internal class TerminalCustomGlyphs(records: List<TerminalCustomGlyphRecord>) {
|
||||
private val glyphs: Map<String, TerminalCustomGlyph> =
|
||||
records.associate { it.char to TerminalCustomGlyph(it) }
|
||||
|
||||
operator fun get(text: String): TerminalCustomGlyph? = glyphs[text]
|
||||
|
||||
companion object {
|
||||
val EMPTY = TerminalCustomGlyphs(emptyList())
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val MEASURE_TEXT = "mmmmmmmmmm"
|
||||
|
||||
/**
|
||||
* The terminal font at one pixel size: a paint per face (regular, bold, italic, bold italic) and
|
||||
* the cell those faces are laid out on. Cells are whole device pixels so every column origin is
|
||||
* exact. Platform fallback fonts cover characters the family lacks.
|
||||
*/
|
||||
internal class TerminalFont(val family: String, val sizePx: Float, val density: Float) {
|
||||
private val faces: Array<Paint> = Array(4) { face ->
|
||||
Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
|
||||
typeface = Typeface.create(family, typefaceStyle(face))
|
||||
textSize = sizePx
|
||||
}
|
||||
}
|
||||
|
||||
val cellWidthPx: Int
|
||||
val cellHeightPx: Int
|
||||
val baselinePx: Int
|
||||
val underlineOffsetPx: Float
|
||||
val underlineThicknessPx: Float
|
||||
val strikeOffsetPx: Float
|
||||
val strikeThicknessPx: Float
|
||||
|
||||
init {
|
||||
val regular = faces[0]
|
||||
cellWidthPx = max(1, (regular.measureText(MEASURE_TEXT) / MEASURE_TEXT.length).roundToInt())
|
||||
val metrics = regular.fontMetricsInt
|
||||
cellHeightPx = max(1, metrics.descent - metrics.ascent)
|
||||
baselinePx = -metrics.ascent
|
||||
underlineOffsetPx = regular.underlinePosition
|
||||
underlineThicknessPx = max(1f, regular.underlineThickness)
|
||||
strikeOffsetPx = regular.strikeThruPosition
|
||||
strikeThicknessPx = max(1f, regular.strikeThruThickness)
|
||||
}
|
||||
|
||||
/** Paint for a style's face; callers only change its color. */
|
||||
fun paint(flags: Int): Paint = faces[face(flags)]
|
||||
|
||||
fun sameAs(family: String, sizePx: Float, density: Float): Boolean =
|
||||
this.family == family && this.sizePx == sizePx && this.density == density
|
||||
|
||||
companion object {
|
||||
fun face(flags: Int): Int =
|
||||
(if (flags and TerminalStyleFlags.BOLD != 0) 1 else 0) +
|
||||
(if (flags and TerminalStyleFlags.ITALIC != 0) 2 else 0)
|
||||
|
||||
private fun typefaceStyle(face: Int): Int = when (face) {
|
||||
1 -> Typeface.BOLD
|
||||
2 -> Typeface.ITALIC
|
||||
3 -> Typeface.BOLD_ITALIC
|
||||
else -> Typeface.NORMAL
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import expo.modules.kotlin.records.Field
|
||||
import expo.modules.kotlin.records.Record
|
||||
|
||||
class TerminalCursorRecord : Record {
|
||||
@Field val row: Int = 0
|
||||
@Field val col: Int = 0
|
||||
}
|
||||
|
||||
class TerminalSelectionRecord : Record {
|
||||
@Field val startRow: Int = 0
|
||||
@Field val startCol: Int = 0
|
||||
@Field val endRow: Int = 0
|
||||
@Field val endCol: Int = 0
|
||||
}
|
||||
|
||||
class TerminalFrameRecord : Record {
|
||||
@Field val columns: Int = 0
|
||||
/** Encoded rows, `cells;styles;text`; see `frame-encoding.ts`. */
|
||||
@Field val rows: List<String> = emptyList()
|
||||
@Field val cursor: TerminalCursorRecord? = null
|
||||
@Field val selection: TerminalSelectionRecord? = null
|
||||
}
|
||||
|
||||
class TerminalThemeRecord : Record {
|
||||
@Field val background: Double = 0.0
|
||||
@Field val cursor: Double = 0.0
|
||||
@Field val selectionBackground: Double = 0.0
|
||||
}
|
||||
|
||||
class TerminalCustomGlyphRecord : Record {
|
||||
@Field val char: String = ""
|
||||
@Field val rects: DoubleArray = DoubleArray(0)
|
||||
@Field val path: DoubleArray = DoubleArray(0)
|
||||
@Field val strokeWidth: Double = 0.0
|
||||
}
|
||||
|
||||
internal object TerminalStyleFlags {
|
||||
const val BOLD = 1
|
||||
const val ITALIC = 2
|
||||
const val UNDERLINE = 4
|
||||
const val STRIKETHROUGH = 8
|
||||
}
|
||||
|
||||
/** JS sends colors as unsigned ARGB numbers; Android paints signed ARGB ints. */
|
||||
internal fun argb(value: Double): Int = value.toLong().toInt()
|
||||
|
||||
/** One row as the caller described it: cell text, column spans and style spans. */
|
||||
internal class TerminalRowSource private constructor(
|
||||
val text: String,
|
||||
val cells: IntArray,
|
||||
val styles: DoubleArray,
|
||||
) {
|
||||
companion object {
|
||||
/** Decodes `cells;styles;text`, where both number lists are comma separated. */
|
||||
fun decode(encoded: String): TerminalRowSource {
|
||||
val cellsEnd = encoded.indexOf(';')
|
||||
val stylesEnd = encoded.indexOf(';', cellsEnd + 1)
|
||||
val cells = parseNumbers(encoded, 0, cellsEnd)
|
||||
val styles = parseNumbers(encoded, cellsEnd + 1, stylesEnd)
|
||||
return TerminalRowSource(
|
||||
encoded.substring(stylesEnd + 1),
|
||||
IntArray(cells.size) { cells[it].toInt() },
|
||||
DoubleArray(styles.size) { styles[it].toDouble() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseNumbers(encoded: String, start: Int, end: Int): LongArray {
|
||||
if (end <= start) return LongArray(0)
|
||||
var count = 1
|
||||
for (index in start until end) if (encoded[index] == ',') count += 1
|
||||
val numbers = LongArray(count)
|
||||
var slot = 0
|
||||
for (index in start until end) {
|
||||
val char = encoded[index]
|
||||
if (char == ',') slot += 1 else numbers[slot] = numbers[slot] * 10 + (char - '0')
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class TerminalSelection(record: TerminalSelectionRecord) {
|
||||
private val startRow = record.startRow
|
||||
private val startCol = record.startCol
|
||||
private val endRow = record.endRow
|
||||
private val endCol = record.endCol
|
||||
|
||||
fun intersectsRow(row: Int): Boolean = row in startRow..endRow
|
||||
|
||||
/** A cell is selected when any column it covers is inside the inclusive range. */
|
||||
fun contains(row: Int, col: Int, span: Int): Boolean {
|
||||
if (!intersectsRow(row)) return false
|
||||
val firstCol = if (row == startRow) startCol else 0
|
||||
val lastCol = if (row == endRow) endCol else Int.MAX_VALUE
|
||||
return col + span - 1 >= firstCol && col <= lastCol
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user