From 2651b5e9bdd4574d5cb2a1d69d31489dbbd6583b Mon Sep 17 00:00:00 2001 From: Chase Cichorz Date: Fri, 6 Nov 2026 14:28:04 -0600 Subject: [PATCH] Add Remote Braille IPC to the host and both SDKs remote.braille requests and events carry a typed op under a braille-stream lease: the controller's width and gestures for a controlled reader, rendered cells back to its child, and cells, local geometry and display gestures for a controller. Direction is validated per role; a full mailbox revokes the lease. No display owner is attached yet. Co-Authored-By: Claude Opus 5.5 --- docs/tentacle-remote-braille.md | 24 ++++ internal/tentacle/remote.go | 12 ++ internal/tentacle/remote_braille.go | 79 ++++++++++++ internal/tentacle/remote_braille_test.go | 119 ++++++++++++++++++ internal/tentacle/session.go | 1 + internal/tentaclewire/remote_braille.go | 121 +++++++++++++++++++ internal/tentaclewire/remote_braille_test.go | 44 +++++++ internal/tentaclewire/session.go | 8 +- sdk/go/tentacle/remote_braille.go | 16 +++ sdk/python/cthulhu_tentacle/session.py | 55 ++++++++- 10 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 docs/tentacle-remote-braille.md create mode 100644 internal/tentacle/remote_braille.go create mode 100644 internal/tentacle/remote_braille_test.go create mode 100644 internal/tentaclewire/remote_braille.go create mode 100644 internal/tentaclewire/remote_braille_test.go create mode 100644 sdk/go/tentacle/remote_braille.go diff --git a/docs/tentacle-remote-braille.md b/docs/tentacle-remote-braille.md new file mode 100644 index 0000000..d2bd462 --- /dev/null +++ b/docs/tentacle-remote-braille.md @@ -0,0 +1,24 @@ +# Remote Braille IPC + +Protocol 1.1 carries Remote Braille through one child-to-host method, +`remote.braille`, and one host-to-child event kind of the same name. Both need +the current process session's live lease with the separately approved +`braille-stream` grant; requests share the operation sequence. Every message +carries one `braille` object whose `op` selects its form: + +| `op` | Direction and role | Fields | +| --- | --- | --- | +| `size` | controlled child to host | `width` 0–4,096: the controller's display; 0 stops export | +| `gesture` | controlled child to host; host to controller child | `gesture` `pan-left`, `pan-right` or `route`, with `index` for route | +| `cells` | host to controlled child; controller child to host | `cells`: 1–4,096 final dot values 0–255, never text | +| `geometry` | controller child to host (no width); host reply event with `width` | 0 means no local display | + +The request reply acknowledges admission into a 16-item reader mailbox; a full +mailbox revokes the lease. Without an attached reader owner the method returns +owner-unavailable. No AT-SPI identity, object path, routing map or native +handle crosses this boundary: routes are cell indexes into the latest exported +frame only. `Session.EnableRemoteBraille`, `PublishRemoteBraille`, the Go +`RemoteBraille` types and validators and the Python `valid_remote_braille` and +`valid_remote_braille_request` validators implement the contract. Go/Python +SDK-process tests cover each direction, role denial, invalid values, +saturation and revocation. diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go index 3aaeab6..707ff64 100644 --- a/internal/tentacle/remote.go +++ b/internal/tentacle/remote.go @@ -191,6 +191,16 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) { } } } + if session.remoteBraille != nil { + drainBraille: + for { + select { + case <-session.remoteBraille: + default: + break drainBraille + } + } + } if session.remoteCapture != nil { drainCapture: for { @@ -292,6 +302,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage return session.remote_key_locked(params) case "remote.focus": return session.remote_focus_locked(params) + case "remote.braille": + return session.remote_braille_locked(params) case "remote.open": var request tentaclewire.RemoteOpen if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) { diff --git a/internal/tentacle/remote_braille.go b/internal/tentacle/remote_braille.go new file mode 100644 index 0000000..b380c71 --- /dev/null +++ b/internal/tentacle/remote_braille.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "encoding/json" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +// RemoteBrailleDelivery is admitted Braille work for the reader's owner. +// The owner must recheck Context and RemoteContext(braille-stream) and its own +// display policy before any effect. Role is the lease's core-assigned role. +type RemoteBrailleDelivery struct { + Context context.Context + Lease tentaclewire.RemoteLease + Role string + Sequence uint64 + Braille tentaclewire.RemoteBraille +} + +// EnableRemoteBraille attaches one reader-owned Braille mailbox. It performs +// no display setup and grants no authority. +func (session *Session) EnableRemoteBraille() <-chan RemoteBrailleDelivery { + session.mutex.Lock() + defer session.mutex.Unlock() + if session.remoteBraille == nil { + session.remoteBraille = make(chan RemoteBrailleDelivery, tentaclewire.RemoteBrailleQueueSize) + } + return session.remoteBraille +} + +func (session *Session) remote_braille_locked(params json.RawMessage) (any, *RPCError) { + var request tentaclewire.RemoteBrailleRequest + if decode_strict(params, &request) != nil { + return nil, &RPCError{Code: -32602, Message: "Invalid session parameters"} + } + lease := tentaclewire.RemoteLease{Lease: request.Lease, Epoch: request.Epoch} + access := session.remote_access_locked(lease, "braille-stream") + if access == nil || request.Sequence <= access.sequence || !tentaclewire.ValidRemoteBrailleFor(access.role, true, request.Braille) { + return nil, &RPCError{Code: -32001, Message: "Session permission denied"} + } + if session.remoteBraille == nil { + return nil, &RPCError{Code: -32601, Message: "Remote Braille owner unavailable"} + } + access.sequence = request.Sequence + select { + case session.remoteBraille <- RemoteBrailleDelivery{Context: access.ctx, Lease: lease, Role: access.role, Sequence: request.Sequence, Braille: request.Braille}: + return struct{}{}, nil + default: + session.revoke_remote_locked("local", true) + return nil, &RPCError{Code: -32002, Message: "Remote Braille queue full"} + } +} + +// PublishRemoteBraille sends one Braille operation to the child. The owner +// supplies only rendered cells (controlled), local geometry or local display +// gestures (controller); never text, routing identities or native handles. +func (session *Session) PublishRemoteBraille(lease tentaclewire.RemoteLease, value tentaclewire.RemoteBraille) error { + session.mutex.Lock() + defer session.mutex.Unlock() + access := session.remote_access_locked(lease, "braille-stream") + if access == nil || !tentaclewire.ValidRemoteBrailleFor(access.role, false, value) { + return ErrPermission + } + value.Cells = append([]int(nil), value.Cells...) + if len(value.Cells) == 0 { + value.Cells = nil + } + event := tentaclewire.SessionEvent{Kind: "remote.braille", Lease: lease.Lease, Epoch: lease.Epoch, Braille: &value} + if !session.remote_event_locked(&event) { + session.fail_locked(ErrLimit) + return ErrLimit + } + return nil +} diff --git a/internal/tentacle/remote_braille_test.go b/internal/tentacle/remote_braille_test.go new file mode 100644 index 0000000..3712959 --- /dev/null +++ b/internal/tentacle/remote_braille_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +func brailleRequest(lease tentaclewire.RemoteLease, sequence uint64, value tentaclewire.RemoteBraille) tentaclewire.RemoteBrailleRequest { + return tentaclewire.RemoteBrailleRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Braille: value} +} + +func TestRemoteBrailleSDKDirectionsAndEvents(t *testing.T) { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session := startSessionSDK(t, manager, language, "braille", "braille-stream") + lease := speech_fixture_lease(t, session, "controlled", []string{"output-observe", "braille-stream"}) + size := tentaclewire.RemoteBraille{Op: "size", Width: 40} + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, size)); code != -32601 { + t.Fatal("Braille admitted without owner", code) + } + deliveries := session.EnableRemoteBraille() + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, size)); code != 0 { + t.Fatal("size refused", code) + } + if delivery := <-deliveries; delivery.Role != "controlled" || !reflect.DeepEqual(delivery.Braille, size) || delivery.Lease != lease { + t.Fatal("size delivery changed", delivery) + } + route := tentaclewire.RemoteBraille{Op: "gesture", Gesture: "route", Index: 7} + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 2, route)); code != 0 { + t.Fatal("gesture refused", code) + } + <-deliveries + cells := tentaclewire.RemoteBraille{Op: "cells", Cells: []int{1, 255, 0}} + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 3, cells)); code != -32001 { + t.Fatal("controlled child sent display cells to its reader", code) + } + if err := session.PublishRemoteBraille(lease, cells); err != nil { + t.Fatal(err) + } + if event := nextSDKEvent(t, session); event.Kind != "remote.braille" || event.Braille == nil || !reflect.DeepEqual(*event.Braille, cells) { + t.Fatal("cells event changed", event) + } + if !errors.Is(session.PublishRemoteBraille(lease, route), ErrPermission) { + t.Fatal("controlled reader published a gesture") + } + session.RevokeRemote() + nextSDKEvent(t, session) + lease = speech_fixture_lease(t, session, "controller", []string{"remote-present", "braille-stream"}) + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, cells)); code != 0 { + t.Fatal("controller cells refused", code) + } + if delivery := <-deliveries; delivery.Role != "controller" || !reflect.DeepEqual(delivery.Braille.Cells, cells.Cells) { + t.Fatal("controller cells changed", delivery) + } + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 2, size)); code != -32001 { + t.Fatal("controller sent a size request", code) + } + for _, value := range []tentaclewire.RemoteBraille{{Op: "geometry", Width: 32}, {Op: "gesture", Gesture: "pan-right"}} { + if err := session.PublishRemoteBraille(lease, value); err != nil { + t.Fatal(err) + } + if event := nextSDKEvent(t, session); event.Braille == nil || !reflect.DeepEqual(*event.Braille, value) { + t.Fatal("controller event changed", event) + } + } + data, _ := json.Marshal(brailleRequest(lease, 3, cells)) + for _, raw := range []string{ + strings.Replace(string(data), `[1,255,0]`, `[1,256,0]`, 1), + strings.Replace(string(data), `"op":"cells"`, `"op":"text"`, 1), + strings.Replace(string(data), `"op":"cells"`, `"op":"cells","width":3`, 1), + strings.Replace(string(data), `"sequence":3`, `"sequence":3,"origin":"local"`, 1), + } { + if _, code := remoteCall(t, session, "remote.braille", json.RawMessage(raw)); code != -32602 { + t.Fatal("invalid Braille admitted", code, raw) + } + } + session.RevokeRemote() + nextSDKEvent(t, session) + if !errors.Is(session.PublishRemoteBraille(lease, cells), ErrPermission) { + t.Fatal("retired lease published Braille") + } + }) + } +} + +func TestRemoteBrailleSDKGrantAndSaturation(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session := startSessionSDK(t, manager, "go", "braille", "braille-stream") + deliveries := session.EnableRemoteBraille() + lease := speech_fixture_lease(t, session, "controlled", []string{"output-observe"}) + size := tentaclewire.RemoteBraille{Op: "size", Width: 40} + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, size)); code != -32001 { + t.Fatal("Braille admitted without grant", code) + } + session.RevokeRemote() + invokeSessionSDK(t, session, "event", nil) + lease = speech_fixture_lease(t, session, "controlled", []string{"braille-stream"}) + for sequence := uint64(1); sequence <= tentaclewire.RemoteBrailleQueueSize; sequence++ { + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, sequence, size)); code != 0 { + t.Fatal("refused before saturation", code) + } + } + if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 99, size)); code != -32002 { + t.Fatal("saturation did not fail", code) + } + if _, err := session.RemoteContext(lease, "braille-stream"); err == nil || len(deliveries) != 0 { + t.Fatal("saturation left the lease or queue") + } +} diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go index 7b7ae0e..394414e 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -79,6 +79,7 @@ type Session struct { remoteSpeech chan RemoteSpeechDelivery remoteKeys chan RemoteKeyDelivery remoteCapture chan RemoteFocusDelivery + remoteBraille chan RemoteBrailleDelivery } func (session *Session) Ready() <-chan struct{} { return session.ready } diff --git a/internal/tentaclewire/remote_braille.go b/internal/tentaclewire/remote_braille.go new file mode 100644 index 0000000..d89acd8 --- /dev/null +++ b/internal/tentaclewire/remote_braille.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import "encoding/json" + +const ( + RemoteBrailleMaxCells = 4096 + RemoteBrailleQueueSize = 16 +) + +// RemoteBraille carries one Braille operation. Exactly one form is valid per +// direction and role: +// +// - "size" (controlled child to host): Width is the controller's display +// width; zero stops export. +// - "gesture" (controlled child to host, or host to controller child): +// Gesture is "pan-left", "pan-right" or "route"; Index is the route cell. +// - "cells" (controller child to host, or host to controlled child): final +// dot cells, 1 to 4,096 values of 0 to 255, never text to translate. +// - "geometry" (controller child to host asks; host to controller child +// answers with Width, zero meaning no local display). +type RemoteBraille struct { + Op string `json:"op"` + Width int `json:"width,omitempty"` + Gesture string `json:"gesture,omitempty"` + Index int `json:"index,omitempty"` + Cells []int `json:"cells,omitempty"` +} + +type RemoteBrailleRequest struct { + RemoteOperation + Braille RemoteBraille `json:"braille"` +} + +func validCells(cells []int) bool { + if len(cells) == 0 || len(cells) > RemoteBrailleMaxCells { + return false + } + for _, cell := range cells { + if cell < 0 || cell > 255 { + return false + } + } + return true +} + +// ValidRemoteBraille checks representation only. Role and direction are +// checked by ValidRemoteBrailleFor. +func ValidRemoteBraille(value RemoteBraille) bool { + switch value.Op { + case "size": + return value.Width >= 0 && value.Width <= RemoteBrailleMaxCells && value.Gesture == "" && value.Index == 0 && value.Cells == nil + case "geometry": + return value.Width >= 0 && value.Width <= RemoteBrailleMaxCells && value.Gesture == "" && value.Index == 0 && value.Cells == nil + case "gesture": + switch value.Gesture { + case "pan-left", "pan-right": + return value.Index == 0 && value.Width == 0 && value.Cells == nil + case "route": + return value.Index >= 0 && value.Index < RemoteBrailleMaxCells && value.Width == 0 && value.Cells == nil + } + return false + case "cells": + return validCells(value.Cells) && value.Width == 0 && value.Gesture == "" && value.Index == 0 + } + return false +} + +// ValidRemoteBrailleFor checks direction: request is true for child-to-host. +func ValidRemoteBrailleFor(role string, request bool, value RemoteBraille) bool { + if !ValidRemoteBraille(value) { + return false + } + switch { + case role == "controlled" && request: + return value.Op == "size" || value.Op == "gesture" + case role == "controlled": + return value.Op == "cells" + case role == "controller" && request: + return value.Op == "cells" || value.Op == "geometry" && value.Width == 0 + case role == "controller": + return value.Op == "gesture" || value.Op == "geometry" + } + return false +} + +func ValidRemoteBrailleRequest(request RemoteBrailleRequest) bool { + return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0 && ValidRemoteBraille(request.Braille) +} + +func (value *RemoteBraille) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if DecodeStrict(data, &fields) != nil || fields["op"] == nil { + return ErrProtocol + } + type plain RemoteBraille + var decoded plain + if DecodeStrict(data, &decoded) != nil || !ValidRemoteBraille(RemoteBraille(decoded)) { + return ErrProtocol + } + *value = RemoteBraille(decoded) + return nil +} + +func (request *RemoteBrailleRequest) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if DecodeStrict(data, &fields) != nil || len(fields) != 4 || fields["lease"] == nil || fields["epoch"] == nil || + fields["sequence"] == nil || fields["braille"] == nil { + return ErrProtocol + } + type plain RemoteBrailleRequest + var decoded plain + if DecodeStrict(data, &decoded) != nil || !ValidRemoteBrailleRequest(RemoteBrailleRequest(decoded)) { + return ErrProtocol + } + *request = RemoteBrailleRequest(decoded) + return nil +} diff --git a/internal/tentaclewire/remote_braille_test.go b/internal/tentaclewire/remote_braille_test.go new file mode 100644 index 0000000..9d59865 --- /dev/null +++ b/internal/tentaclewire/remote_braille_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import ( + "encoding/json" + "testing" +) + +func TestRemoteBrailleDirections(t *testing.T) { + size := RemoteBraille{Op: "size", Width: 40} + route := RemoteBraille{Op: "gesture", Gesture: "route", Index: 3} + cells := RemoteBraille{Op: "cells", Cells: []int{0, 255}} + query := RemoteBraille{Op: "geometry"} + answer := RemoteBraille{Op: "geometry", Width: 32} + for _, test := range []struct { + role string + request bool + value RemoteBraille + want bool + }{ + {"controlled", true, size, true}, {"controlled", true, route, true}, {"controlled", true, cells, false}, + {"controlled", false, cells, true}, {"controlled", false, route, false}, + {"controller", true, cells, true}, {"controller", true, query, true}, {"controller", true, answer, false}, + {"controller", true, size, false}, {"controller", false, route, true}, {"controller", false, answer, true}, + {"controller", false, cells, false}, {"observer", true, size, false}, + } { + if got := ValidRemoteBrailleFor(test.role, test.request, test.value); got != test.want { + t.Fatalf("%s request=%t %+v = %t", test.role, test.request, test.value, got) + } + } + for _, raw := range []string{ + `{"op":"cells","cells":[]}`, `{"op":"cells","cells":[-1]}`, `{"op":"size","width":4097}`, + `{"op":"gesture","gesture":"route","index":4096}`, `{"op":"gesture","gesture":"script"}`, + `{"op":"gesture","gesture":"pan-left","index":1}`, `{"width":3}`, `{"op":"size","width":3,"text":"x"}`, + } { + var value RemoteBraille + if json.Unmarshal([]byte(raw), &value) == nil { + t.Fatal("invalid Braille decoded", raw) + } + } +} diff --git a/internal/tentaclewire/session.go b/internal/tentaclewire/session.go index 549cb58..d1ca46a 100644 --- a/internal/tentaclewire/session.go +++ b/internal/tentaclewire/session.go @@ -77,6 +77,7 @@ type SessionEvent struct { Speech *RemoteSpeech `json:"speech,omitempty"` Input *RemoteCapturedKey `json:"input,omitempty"` Focus string `json:"focus,omitempty"` + Braille *RemoteBraille `json:"braille,omitempty"` } func ValidRole(role string) bool { return role == "controller" || role == "controlled" } @@ -97,7 +98,7 @@ func ValidSessionEvent(event SessionEvent) bool { if event.Sequence == 0 || event.Epoch == 0 { return false } - if event.Kind != "remote.input" && event.Input != nil || event.Kind != "remote.focus" && event.Focus != "" { + if event.Kind != "remote.input" && event.Input != nil || event.Kind != "remote.focus" && event.Focus != "" || event.Kind != "remote.braille" && event.Braille != nil { return false } switch event.Kind { @@ -107,6 +108,11 @@ func ValidSessionEvent(event SessionEvent) bool { return event.Speech == nil && ((event.Action == "" && len(event.Lease) == 32) || (len(event.Action) == 32 && event.Lease == "")) && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason) case "remote.input": return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech == nil && event.Input != nil && ValidRemoteCapturedKey(*event.Input) + case "remote.braille": + // Role is not carried on events; either direction-valid form is accepted + // and the receiving child applies its own role. + return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech == nil && event.Braille != nil && + (ValidRemoteBrailleFor("controlled", false, *event.Braille) || ValidRemoteBrailleFor("controller", false, *event.Braille)) case "remote.focus": return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Speech == nil && ValidRemoteFocusEvent(event.Focus, event.Reason) case "remote.output": diff --git a/sdk/go/tentacle/remote_braille.go b/sdk/go/tentacle/remote_braille.go new file mode 100644 index 0000000..fa960f7 --- /dev/null +++ b/sdk/go/tentacle/remote_braille.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import "git.stormux.org/storm/cthulhu/internal/tentaclewire" + +type RemoteBraille = tentaclewire.RemoteBraille +type RemoteBrailleRequest = tentaclewire.RemoteBrailleRequest + +func ValidRemoteBraille(value RemoteBraille) bool { return tentaclewire.ValidRemoteBraille(value) } + +func ValidRemoteBrailleRequest(request RemoteBrailleRequest) bool { + return tentaclewire.ValidRemoteBrailleRequest(request) +} diff --git a/sdk/python/cthulhu_tentacle/session.py b/sdk/python/cthulhu_tentacle/session.py index ccf68e4..414872f 100644 --- a/sdk/python/cthulhu_tentacle/session.py +++ b/sdk/python/cthulhu_tentacle/session.py @@ -166,6 +166,48 @@ def valid_remote_focus_request(request): return False +def valid_remote_braille(value, role=None, request=None): + """Validate one Remote Braille operation; role/request also check direction.""" + try: + _fields(value, ("op",), ("width", "gesture", "index", "cells")) + op = value["op"] + width, gesture, index = value.get("width", 0), value.get("gesture", ""), value.get("index", 0) + cells = value.get("cells") + if type(width) is not int or type(index) is not int or not isinstance(gesture, str): + return False + if op in ("size", "geometry"): + ok = 0 <= width <= 4096 and gesture == "" and index == 0 and cells is None + elif op == "gesture": + ok = (width == 0 and cells is None + and (gesture in ("pan-left", "pan-right") and index == 0 + or gesture == "route" and 0 <= index < 4096)) + elif op == "cells": + ok = (isinstance(cells, list) and 1 <= len(cells) <= 4096 + and all(type(cell) is int and 0 <= cell <= 255 for cell in cells) + and width == 0 and gesture == "" and index == 0) + else: + return False + if not ok or role is None: + return ok + if role == "controlled": + return op in ("size", "gesture") if request else op == "cells" + if role == "controller": + return (op == "cells" or op == "geometry" and width == 0) if request else op in ("gesture", "geometry") + return False + except (ProtocolError, TypeError, ValueError): + return False + + +def valid_remote_braille_request(request): + """Validate remote.braille parameters; display effects remain owned by core.""" + try: + _fields(request, ("lease", "epoch", "sequence", "braille")) + return (_token(request["lease"]) and _positive(request["epoch"]) + and _positive(request["sequence"]) and valid_remote_braille(request["braille"])) + except (ProtocolError, TypeError, ValueError, UnicodeError): + return False + + REMOTE_FOCUS_REASONS = frozenset(("requested", "escape", "local", "unavailable")) @@ -358,11 +400,12 @@ class SessionClient: waiter.put_nowait(value) def _event(self, event): - _fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason", "speech", "input", "focus")) + _fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason", "speech", "input", "focus", "braille")) if not _positive(event["sequence"]) or not _positive(event["epoch"]): raise ProtocolError("Invalid event sequence") if (event["kind"] != "remote.input" and "input" in event - or event["kind"] != "remote.focus" and event.get("focus", "") != ""): + or event["kind"] != "remote.focus" and event.get("focus", "") != "" + or event["kind"] != "remote.braille" and "braille" in event): raise ProtocolError("Invalid session event") if event["kind"] == "remote.action": grants = event.get("grants") @@ -386,6 +429,14 @@ class SessionClient: or event.get("reason", "") != "" or "speech" in event or not valid_remote_captured_key(event.get("input"))): raise ProtocolError("Invalid input event") + elif event["kind"] == "remote.braille": + braille = event.get("braille") + if (not _token(event.get("lease")) or event.get("action", "") != "" + or event.get("role", "") != "" or event.get("grants", []) != [] + or event.get("reason", "") != "" or "speech" in event + or not (valid_remote_braille(braille, "controlled", False) + or valid_remote_braille(braille, "controller", False))): + raise ProtocolError("Invalid Braille event") elif event["kind"] == "remote.focus": if (not _token(event.get("lease")) or event.get("action", "") != "" or event.get("role", "") != "" or event.get("grants", []) != []