Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ config :desktop, :menu_adapter, DesktopWebview.Menu.Adapter
5. **No native unit-test frameworks** (no XCTest, etc.) as the source of truth. Extend the shared Elixir E2E suite instead. Test-only RPC (`test.*`) is allowed when gated by `--edw-test-rpc`.
6. **Status matrices are authoritative.** Mark a feature `done` on a platform only when Elixir E2E covers it.
7. **Per-platform native code stays isolated.** Do not share Swift/C++/GTK UI code across `native/*` until a deliberate shared core exists.
8. **Single-instance and `--edw-rpc` go through the control socket.** Do not replace the Elixir TCP client. Second launch uses `instance.activate`; `--edw-rpc` uses `instance.eval` → host→client `rpc.eval`. Do not use `erl_call` / epmd for these paths.

## Protocol ownership

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ See [docs/packaging.md](docs/packaging.md).
- [Protocol](docs/protocol.md) — framing, methods, behavioral semantics, test RPC
- [Porting](docs/porting.md) — checklist for Windows / Linux hosts
- [Packaging](docs/packaging.md) — ini, argv, layouts, binaries
- [`--edw-rpc`](docs/specs/feature-edw-rpc.md) — one-shot Elixir via `erl_call`
- [`--edw-rpc`](docs/specs/feature-edw-rpc.md) — one-shot Elixir via control socket + `rpc.eval`
- [Single-instance](docs/specs/feature-single-instance.md) — host-owned lock and second-launch activate
- [BEAM restart / `--edw-recover`](docs/specs/feature-beam-restart.md)
- [Desktop integration](docs/desktop-integration.md)
- [AGENTS.md](AGENTS.md) — contributor / agent rules
Expand Down
28 changes: 20 additions & 8 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,15 @@ app_name = my_app
args = start
working_dir = beam
enabled = true
# Optional overrides for --edw-rpc (else releases/COOKIE + vm.args)
# node = my_app@127.0.0.1
# cookie = secret
# cookie_file = releases/COOKIE

[network]
host = 127.0.0.1
port = 0

[lifetime]
mode = reconnect
# multi (default) | single — packaged apps set single
# instances = single
# instance_id = ddrive
restart_beam = true
restart_max_attempts = 0
restart_backoff_ms = 500
Expand All @@ -122,8 +120,10 @@ recovery_after = 3
```

One-shot CLI (`--edw-rpc`, `--edw-recover`) does not listen, print
`listening`, or spawn `start`. See [feature-edw-rpc.md](specs/feature-edw-rpc.md)
and [feature-beam-restart.md](specs/feature-beam-restart.md).
`listening`, or spawn `start`. `--edw-rpc` is a control-socket client of a
running single-instance host. See [feature-edw-rpc.md](specs/feature-edw-rpc.md),
[feature-single-instance.md](specs/feature-single-instance.md), and
[feature-beam-restart.md](specs/feature-beam-restart.md).

## CLI (`--edw-*`)

Expand All @@ -140,7 +140,9 @@ argv is forwarded to the BEAM release.
| `--edw-test-rpc` | Enable `test.*` JSON-RPC methods |
| `--edw-beam-path=DIR` | Override beam release directory |
| `--edw-beam-app=NAME` | Override release script name |
| `--edw-rpc <expr>` | One-shot Elixir eval on the running node via `erl_call` |
| `--edw-instances=multi\|single` | Instance mode (default `multi`) |
| `--edw-instance-id=NAME` | Control-socket lock name (default: host exe basename) |
| `--edw-rpc <expr>` | One-shot Elixir eval via control socket `instance.eval` |
| `--edw-recover` | One-shot Mix `eval` of `recovery_script` (no application start) |
| `--edw-recovery-script=PATH` | Recovery `.exs` path |
| `--edw-recovery-after=N` | Startup crashes before automatic recovery (default 3) |
Expand Down Expand Up @@ -193,6 +195,16 @@ OTP and Elixir load; the application does not start. Then the host respawns

`--edw-rpc` and `--edw-recover` are mutually exclusive.

### Single-instance

Default `instances = multi` so `--edw-no-beam` E2E can run more than one host.
Packaged apps set `instances = single`. The first host binds the control
socket. A second launch sends `instance.activate` (not a second EDW TCP
client) and exits 0. See [feature-single-instance.md](specs/feature-single-instance.md).

When the host spawns BEAM, it sets `RELEASE_DISTRIBUTION=none` if that
environment key is unset.

## Binaries

| Platform | Delivery | Artifact name |
Expand Down
10 changes: 8 additions & 2 deletions docs/porting.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,16 @@ Do **not** copy macOS UI code into other platforms — share only the protocol.
9. **OS events** — reopen / open URL / open file where the OS supports them
10. **Packaged BEAM spawn** + **CI artifact** on tag draft releases
11. **Test RPC** behind `--edw-test-rpc`; run shared E2E
12. **`--edw-rpc`** — one-shot Elixir via erts `erl_call` (cookie/node from the
release). No UI. See [specs/feature-edw-rpc.md](specs/feature-edw-rpc.md).
12. **`--edw-rpc`** — one-shot Elixir via the control socket (`instance.eval`)
and host→client `rpc.eval`. No UI. No `erl_call`. See
[specs/feature-edw-rpc.md](specs/feature-edw-rpc.md).
13. **BEAM restart + `--edw-recover`** — shared backoff, reset counters on
`initialize`, Mix `eval` recovery script. See
[specs/feature-beam-restart.md](specs/feature-beam-restart.md).
14. **Single-instance** — `instances` / `instance_id`, control socket,
`instance.activate`, `RELEASE_DISTRIBUTION=none` when unset. Do not
replace the Elixir TCP client. See
[specs/feature-single-instance.md](specs/feature-single-instance.md).

## HTML file inputs and file-manager drag-and-drop

Expand Down Expand Up @@ -121,6 +126,7 @@ Before flipping a status row to `done`, the corresponding E2E (or an added E2E)
| HTML file input DOM contract | `HTML file input fixture exposes chooser semantics` |
| Locale / OS string | `system locale and os_description` |
| `--edw-rpc` | `test/e2e/rpc_test.exs` |
| Single-instance activate | `test/e2e/instance_test.exs` |
| Restart / `--edw-recover` | `test/e2e/restart_test.exs` |

Platform-specific asserts (e.g. `caps["platform"] == "macos"`) must be generalized when the second host lands — use `:os.type()` / host `initialize.platform`.
Expand Down
26 changes: 23 additions & 3 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,12 @@ Notification (no `id`):
client disconnects (and kills BEAM when the host exits in packaged mode).
BEAM-first / `--edw-no-beam` (dev) always exits the host on client disconnect.

`--edw-rpc` and `--edw-recover` are process-shell commands, not JSON-RPC.
They do not listen. See [packaging.md](packaging.md) and
[specs/feature-edw-rpc.md](specs/feature-edw-rpc.md).
`--edw-rpc` and `--edw-recover` are process-shell commands. They do not
listen and they do not connect to this EDW TCP socket. `--edw-rpc` uses the
**control socket** (`instance.eval`) of a single-instance host; the host then
sends `rpc.eval` on this session. `--edw-recover` stays Mix `eval`. See
[packaging.md](packaging.md), [specs/feature-edw-rpc.md](specs/feature-edw-rpc.md),
and [specs/feature-single-instance.md](specs/feature-single-instance.md).

## Behavioral semantics

Expand Down Expand Up @@ -230,6 +233,9 @@ MAY replace the previous one; document if you support multiple clients. Replace
MUST reset session UI (see `initialize` and reconnect) and MUST NOT treat the
replaced socket as a host-quit signal.

Second app launch and `--edw-rpc` MUST NOT connect to this socket. They use the
control socket in [feature-single-instance.md](specs/feature-single-instance.md).

## Production methods

### `initialize`
Expand Down Expand Up @@ -355,6 +361,20 @@ Events: `event.notification.click`, `event.notification.dismiss`,
quitting. Packaged mode also terminates any BEAM child it spawned.
- Elixir `EventBridge` maps `event.system.quit` → `Desktop.Window.quit/0`.

### `rpc.eval` (host → client)

Used by `--edw-rpc` after the control socket `instance.eval` request. The
Elixir client handles it in `DesktopWebview.Transport` (`Code.eval_string/1` +
`Kernel.inspect/1`). It does not require EventBridge.

```json
{"jsonrpc":"2.0","id":N,"method":"rpc.eval","params":{"expr":"1+1"}}
```

Success result: `{ "inspect": "2" }`. Eval error: JSON-RPC error `-32000`.
No initialized Elixir client: the control-socket `instance.eval` fails
non-zero (this method is not sent).

### Permissions (hybrid)

Host → client **request**:
Expand Down
129 changes: 52 additions & 77 deletions docs/specs/feature-edw-rpc.md
Original file line number Diff line number Diff line change
@@ -1,57 +1,62 @@
# `--edw-rpc` Specification v0.1.0
# `--edw-rpc` Specification v0.2.0

> **Spec type:** Feature
> **Path:** `docs/specs/feature-edw-rpc.md`

## Overview

The native `DesktopWebView` binary exposes a one-shot `--edw-rpc <expr>` CLI.
It evaluates an Elixir expression on a **running** packaged BEAM node through
erts `erl_call`, prints the inspected return value, and exits.
It connects to the **control socket** of a running single-instance host, asks
that host to evaluate an Elixir expression on the existing EDW session
(`rpc.eval`), prints the inspected return value, and exits.

This path does **not** use `erl_call`, a distribution cookie, or `epmd`.

**Integration context:** Host process shell in `native/{macos,windows,linux}/`.
Config discovery follows [docs/packaging.md](../packaging.md). This is not
JSON-RPC (`docs/protocol.md`).
Config discovery follows [docs/packaging.md](../packaging.md). The control
socket is specified in [feature-single-instance.md](feature-single-instance.md).
The host→client method is specified in [docs/protocol.md](../protocol.md).

## Design Principles

1. **One-shot, no UI.** `--edw-rpc` does not listen, print `listening`, spawn
`start`, or create a window.
2. **Elixir in, inspect out.** The public expression is Elixir. The host wraps
it for `erl_call`. Stdout is `Kernel.inspect/1` of the value plus a newline.
3. **Release files supply cookie and node.** Ini may override. The host does
not invent a cookie.
4. **Do not start BEAM.** If the node is down, exit non-zero.
5. **Same contract on every OS.** macOS, Windows, and Linux use the same flags,
discovery order, and exit codes.
6. **Mutually exclusive with `--edw-recover`.**
`start`, create a window, or bind the control socket as a server.
2. **Elixir in, inspect out.** The public expression is Elixir. Stdout is
`Kernel.inspect/1` of the value plus a newline.
3. **Ask the running host.** The CLI is a control-socket client
(`instance.eval`). The host forwards `rpc.eval` to the initialized Elixir
client.
4. **Single-instance only.** If `instances = multi`, or no host holds the
lock, exit non-zero.
5. **Same contract on every OS.** macOS, Windows, and Linux use the same
flags, discovery order, and exit codes.
6. **Mutually exclusive with `--edw-recover`.** `--edw-recover` stays Mix
`eval` and does not use the control socket.

---

## Output Structure

**Do generate:** native CLI handling, packaging docs, Elixir E2E.
**Do generate:** native CLI client, packaging docs, Elixir E2E.

**Do not generate:** JSON-RPC methods, native unit-test frameworks, a second
RPC protocol.
**Do not generate:** `erl_call` cookie/node discovery, a second Elixir TCP
client, native unit-test frameworks.

---

## Type Conventions

| Spec type | Meaning | Examples |
|-----------|---------|----------|
| `elixir_expr` | UTF-8 Elixir source | `1+1`, `node()` |
| `node_name` | Erlang node | `my_app@127.0.0.1`, short `my_app` |
| `cookie` | Distribution cookie string | contents of `releases/COOKIE` |
| `elixir_expr` | UTF-8 Elixir source | `1+1`, `DesktopWebview.Binary.available?()` |
| `instance_id` | Control-socket lock name | `edw-rpc-42` |
| `exit_code` | Process status | `0` success, non-zero failure |

### Normalization

- `--edw-rpc <expr>` (next argv) and `--edw-rpc=<expr>` are the same.
- Relative `beam.path` resolves from the resources / executable directory as
in packaging.md.
- A node name with `@` from `-name` is a long name. `-sname` is a short name.
- The client uses the same ini-over-CLI merge as other overlapping keys
(`--edw-config`, `--edw-instances`, `--edw-instance-id`).

---

Expand All @@ -65,42 +70,18 @@ RPC protocol.
|-----------|------|--------|
| `--edw-rpc` and `--edw-recover` together | non-zero | mutually exclusive |
| Missing expression | non-zero | usage |
| `erl_call` not found | non-zero | path search failed |
| Cookie or node not found | non-zero | discovery failed |
| Node down / `erl_call` fails / eval error | `erl_call` status | `erl_call` stderr |

---

## Discovery

Search order is the same on every OS.

**Cookie**

1. Ini `[beam] cookie`
2. Ini `[beam] cookie_file` (file contents, trim newline)
3. `{beam}/releases/COOKIE`
4. `-setcookie` in `vm.args`

**Node**

1. Ini `[beam] node`
2. `-name` or `-sname` in `{beam}/releases/<vsn>/vm.args` (`start_erl.data` or
first `releases/*/vm.args`)

**`erl_call` binary** (`.exe` on Windows)

1. `{beam}/erts-*/bin/erl_call`
2. `{beam}/lib/erl_interface-*/bin/erl_call`
3. `PATH`
| `instances = multi` | non-zero | no running single-instance host |
| No host / connect failed | non-zero | no running single-instance host |
| No initialized Elixir client | non-zero | control `instance.eval` error |
| Eval error | non-zero | JSON-RPC `-32000` message |

---

## API Surface (Behaviors)

### `--edw-rpc <expr>` → stdout + exit_code

Evaluate `expr` on the running node.
Evaluate `expr` on the Elixir client of the running single-instance host.

**Arguments:**

Expand All @@ -110,34 +91,28 @@ Evaluate `expr` on the running node.

| Condition | Output |
|-----------|--------|
| Success | `inspect(value)` and a newline on stdout and stderr, exit 0 |
| Node down | non-zero |
| Success | `inspect(value)` and a newline on stdout (and stderr), exit 0 |
| No host / multi / not initialized | non-zero |
| Combined with `--edw-recover` | non-zero, no eval |

**Eval method:** Base64-encode `expr`. Pipe Erlang to `erl_call -c <cookie>`
with `-name <node>` (long) or `-sname <node>` (short). Pass `-r` and
`-no_result_term`. Do **not** pass `-s` (that starts a node). The host writes
`Kernel.inspect/1` of the value to stdout (a temp file is allowed; `io:format`
does not reach a pipe).

```erlang
Bin = base64:decode(<<"...">>),
{Val, _} = 'Elixir.Code':eval_string(Bin),
io:format("~ts~n", ['Elixir.Kernel':inspect(Val)]).
```
**Eval method:** Connect to the control socket. Send JSON-RPC
`instance.eval` `{expr}`. The host sends EDW request `rpc.eval` to the
Elixir client. `DesktopWebview.Transport` runs `Code.eval_string/1` and
returns `{inspect}`.

Do not `halt` the remote node.
Do not `halt` the remote VM.

**Examples:**

- `--edw-rpc '1+1'` → stdout `2`
- `--edw-rpc 'node()'` → the remote node name
- `--edw-rpc 'DesktopWebview.Binary.available?()'` → `true` when that
module is loaded in the connected client

**Edge cases:**

- Empty expression → error
- Quotes and newlines in `expr` → Base64 wrap, no shell interpolation of the
remote source
- Quotes and newlines in `expr` → JSON string, no shell interpolation of
the remote source

---

Expand All @@ -149,17 +124,17 @@ source of truth.

## Generated Documentation

Packaging CLI table and ini `[beam] node` / `cookie` keys. Porting checklist
row for `--edw-rpc`.
Packaging CLI table. Porting checklist row for `--edw-rpc`. Protocol
`rpc.eval`.

## Implementation Checklist

- [ ] macOS / Windows / Linux one-shot CLI
- [ ] Discovery order implemented
- [ ] Mutual exclusion with `--edw-recover`
- [ ] E2E cases from tests-edw-rpc.yaml
- [ ] Status row `done` only when E2E is green
- [x] macOS / Windows / Linux one-shot CLI via `instance.eval`
- [x] Mutual exclusion with `--edw-recover`
- [x] E2E cases from tests-edw-rpc.yaml
- [x] Status row `done` only when E2E is green

## Version History

- **v0.1.0** - Initial specification
- **v0.2.0** - Control socket + `rpc.eval`; drop `erl_call`
- **v0.1.0** - Initial specification (`erl_call`)
Loading
Loading