2 Commits
Author SHA1 Message Date
a9eb233a9d Track changes: keep other authors' deletions, credit paragraph splits, exact edit snapshots (split from #54) (#125)
* Track changes: a tracked delete keeps other authors' deletions

Deleting a range with Track Changes on marked every run in it as deleted
by the current author, also text another author had already deleted.
That author's deletion was lost: the change list showed it under the
wrong name, and rejecting the second author's changes brought back text
the first author had deleted. Text that is already deleted now keeps its
deletion and its author, like Word.

* Track changes: a tracked paragraph split credits the new mark to its author

Pressing Enter with Track Changes on inserts a paragraph mark by the
current author, but the paragraph after the split took its mark's
revisions from the text at the caret. Inside or at the end of another
author's insertion, the original paragraph mark then showed as inserted
by that author. The paragraph after the split now keeps the original
mark's revisions; only the new mark is the splitting author's.

* Engine: edit snapshots that take commands back exactly

Session::edit_snapshot and Session::restore let a caller run a command
and take it back without a trace: the document, the selection, the undo
and redo stacks, the open typing group, a pending join_next_undo and
the dirty flag come back as they were, and the layout is recomputed.
Edit > Undo cannot do this: it leaves a redo step, loses the redo stack
the command cleared, and closes the typing group.

At the undo limit (500 steps) a command pushes the oldest step out. The
snapshot keeps the oldest steps when the stack is near the limit and
restore puts them back, so the stack is exact there too (up to four
commands between snapshot and restore).

Restore is exact for commands that add undo steps; edit.undo and
file.new/file.open in between are outside its contract (documented).
The snapshot destructures every Session field, so a new field does not
build until someone decides whether a snapshot must cover it.

Session::undo_depth and the snapshot's doc/dirty/undo_depth accessors
tell a caller whether the command changed anything.

* Tests: hostile_params_never_panic leaves out review.readAloud

Read Aloud hands the document's text to the system's speech synthesizer
(say, PowerShell, spd-say or espeak), so running the test made the
computer speak the sample document. Like the file commands, it is left
out of the sweep.

* Undo: evictions at the limit are counted everywhere edit snapshots rely on them

Merging main brought two paths #125's eviction counter didn't know: push_undo (typing parity, #204) dropped the oldest step without counting it, and a failed command (#153) put the whole stacks back but left the count raised. Either made restore() bring back a step that had never left. Both now keep the count right; restore_is_exact_at_the_undo_limit covers each (both fail without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: lpgn <2556951+lpgn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:07:32 -04:00
bded38dd23 Control port: require a per-window key (split from #54) (#124)
* MCP bridge: send the control key, to loopback addresses only

`wordcraft-cli mcp --connect` now sends the app's control key with every
request. It takes the key from WORDCRAFT_CONTROL_KEY, or else from the
key file the app writes for that port, and reads it again on every new
connection, also after an `unauthorized` reply (the app restarted with
a new key).

The key goes only to loopback addresses: 127.0.0.0/8, ::1 and
`localhost`, and only when the connection really leads to a loopback
address. For any other host the bridge sends no key, prints why at
start, and turns the app's `unauthorized` into an error that says the
key was not sent and how to reach the app (through 127.0.0.1, for
example an SSH tunnel). Other host names are never resolved for this.

The new `wordcraft_mcp::control_key` module holds the rule the app and
the bridge share: the settings folder (the one with ui.json), the
Flatpak instance id and `<settings>/control-key.<instance>`
(`local-<port>` outside Flatpak). No new dependency; wasm-safe.

* Control port: every request needs the window's key

Any local program, and possibly a web page, could drive the app through
its loopback control port. Each window started with --control now has
a key:

- once the port is bound, the app writes a random key (32 bytes from
  the OS, as hex) to <settings>/control-key.<instance>, mode 0600 on
  Unix, replacing a stale file; it removes the file on a clean exit. A
  failed bind writes nothing. <instance> is the Flatpak instance id, or
  local-<port> outside Flatpak (wordcraft_mcp::control_key, the rule
  the MCP bridge reads it with). The settings folder is the one that
  holds ui.json; prefs_path now uses the same helper.
- every request needs the key: top-level "key", or
  {"method":"auth","key":...} once per connection. Only auth reads
  params.key; elsewhere it stays an ordinary parameter (ui.key). The key
  is checked on every request, in time that does not depend on where a
  wrong key differs. A missing or wrong key gets
  {"ok":false,"error":"unauthorized"} and the connection is closed.
- a line over 1 MiB gets "line too long" and the connection is closed
  (the rest of the line is drained first, so the client reads the
  error).
- a connection that sends no authorised request within 30 s of being
  accepted is closed; blank lines and bad JSON do not extend that
  limit. After that it may stay idle.

* Docs: control-port keys

docs/control-protocol.md gets a Keys section: where the key file is
(per platform and per instance), top-level "key" or auth once per
connection, `unauthorized` and the closed connection, the 1 MiB line
limit, the 30 s limit for the first authorised request, and how the MCP
bridge finds the key and sends it only to loopback addresses (an SSH
tunnel for an app on another machine). docs/mcp.md points there, and
the agent guides (AGENTS.md, CLAUDE.md) show the key in their control
channel example.

* Control key: the key-file helpers in their own small crate

wordcraft-control-key (standard library only, Standalone in the layering
table) holds where the key file lives and how to read it. The app and the
MCP bridge both use it, so the app no longer depends on wordcraft-mcp.

* Cargo.lock: wordcraft-control-key follows the workspace version (0.4.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: lpgn <2556951+lpgn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:56:41 -04:00