Skip to content

New braille IPC APIs: BrailleMirror, DirectBrailleWindow, and named-pipe server#19856

Draft
trypsynth wants to merge 6 commits intonvaccess:masterfrom
trypsynth:new-braille-apis
Draft

New braille IPC APIs: BrailleMirror, DirectBrailleWindow, and named-pipe server#19856
trypsynth wants to merge 6 commits intonvaccess:masterfrom
trypsynth:new-braille-apis

Conversation

@trypsynth
Copy link
Copy Markdown
Contributor

@trypsynth trypsynth commented Mar 25, 2026

Link to issue number:

Closes #19843

Summary of the issue:

External processes (e.g. RIM's elevated service component) have no supported way to receive a live mirror of NVDA's braille output or to act as a direct braille display for a foreground window. The existing pre_writeCells / filter_displayDimensions / decide_enabled extension points are not sufficient on their own: they require in-process code, have no HWND-scoping, and block the NVDA main thread if pipe I/O is done naively.

Description of user facing changes:

None for end users. NVDA Remote users with braille displays can continue using braille normally; the refactor is transparent.

Description of developer facing changes:

Two new public APIs in braille:

  • BrailleMirror - abstract class. Register with braille.registerMirror(); receives every display() call and optionally caps the display width via numCells(). Unregister with braille.unregisterMirror().
  • DirectBrailleWindow - HWND-scoped class. While its window is foreground, NVDA rendering is suspended (decide_enabled) and braille gestures are intercepted (decide_executeGesture) and forwarded to onGesture(). Subclass and call activate()/deactivate().
  • braille.injectGesture(gesture) - helper to execute a BrailleDisplayGesture via inputCore.manager, swallowing NoInputGestureAction.

New module braillePipeServer exposes both APIs over a named pipe (\.\pipe\nvda_braille / \.\pipe\nvda_braille_secure). Wire protocol: 4-byte LE uint32 length-prefix + UTF-8 JSON. Pipe DACL grants NT AUTHORITY\SYSTEM read/write so RIM's elevated service can connect on the normal desktop.

Description of development approach:

  • registerMirror lazily hooks pre_writeCells and filter_displayDimensions (shared single handler); unhooks when last mirror unregisters.
  • DirectBrailleWindow hooks decide_enabled and inputCore.decide_executeGesture per instance; optionally hooks filter_displayDimensions if numCells > 0.
  • braillePipeServer uses one accept-loop thread + one _AsyncWriter background thread per connection so pipe I/O never touches the NVDA main thread.
  • _remoteClient refactored: FollowerSession now uses _FollowerBrailleMirror instead of direct pre_writeCells wiring; localMachine.setBrailleDisplaySize / _handleFilterDisplayDimensions removed; pre-existing filter_displayDimensions leak on transport close fixed.

Testing strategy:

30 unit tests added in tests/unit/test_braille/test_mirrorAndDirectWindow.py covering:

  • BrailleMirror register/unregister lifecycle and extension-point hook/unhook
  • Display forwarding to one and multiple mirrors
  • numCells capping logic (zero, smaller, larger, smallest-wins)
  • injectGesture happy path and NoInputGestureAction swallow
  • DirectBrailleWindow activate/deactivate idempotency, decide_enabled and decide_executeGesture routing, filter_displayDimensions registration and capping

Manual testing needed: NVDA Remote with a physical braille display on both sides. I've locally tested with a 20 cell on both sides individually, but am unable to test both at the same time

Known issues with pull request:

RIM client-side integration is not yet complete. Do not merge until the RIM release is coordinated.
LeaderSession in NVDA Remote still wires decide_executeGesture directly rather than via DirectBrailleWindow; should this be migrated in core as well? Needs discussion.

Code Review Checklist:

  • Documentation:
    • Change log entry
    • User Documentation
    • Developer / Technical Documentation
    • Context sensitive help for GUI changes
  • Testing:
    • Unit tests
    • System (end to end) tests
    • Manual testing
  • UX of all users considered:
    • Speech
    • Braille
    • Low Vision
    • Different web browsers
    • Localization in other languages / culture than English
  • API is compatible with existing add-ons.
  • Security precautions taken.

@seanbudd
Copy link
Copy Markdown
Member

seanbudd commented Mar 26, 2026

Hi - please wait for feedback and triage on #19843 before opening a PR

See contributing guidelines

@seanbudd seanbudd closed this Mar 26, 2026
@trypsynth
Copy link
Copy Markdown
Contributor Author

@seanbudd Just for clarity, this PR follows the design doc that was approved by the core team in a recent meeting. Since the implementation is ready for eyes, I’d prefer to keep this open (even as a Draft) so we have a central place for the technical review once your triage is complete.

@seanbudd
Copy link
Copy Markdown
Member

Hi @trypsynth - after re-confirming with the team, I am unclear where you got approval from. We have not discussed or approved the design yet, we will do that when reviewing #19843. When we are happy with the approach and for a PR to be opened, the triaged label will be applied to the issue.

@trypsynth
Copy link
Copy Markdown
Contributor Author

@seanbudd it appears to have been a cummunication error on my part, I initially emailed Quentin about this project to make sure NV Access was okay with it as a concept without us shouting it to the world in case they said no, and got confirmation that nV Access is okay with it after reviewing the design doc I sent over at a recent meeting. I got told to then create an issue to track it, and also for some reason thought a pr was mentioned, but I can't find it now, so quite possible I made that up. In any case, once the issue has been triaged, should we just reopen this existing pull request? This branch has just been sitting idle on my laptop while I work on RIM, and we're getting closer, and when I do need to make teeny fixes, I'm pushing to the same branch.

@seanbudd
Copy link
Copy Markdown
Member

@trypsynth - no problem with the confusion. We will probably need to re-discuss the approach to integration as part of reviewing and triaging the issue. Given the size of this PR, we will probably ask for it to be split into smaller separate PRs

@SaschaCowley SaschaCowley reopened this Apr 23, 2026
@seanbudd seanbudd marked this pull request as ready for review April 28, 2026 01:52
@seanbudd seanbudd requested a review from a team as a code owner April 28, 2026 01:52
@seanbudd seanbudd requested review from Copilot and seanbudd April 28, 2026 01:52
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces new braille IPC-facing core APIs (mirror + direct-braille window) and refactors NVDA Remote’s follower braille handling to use those APIs, alongside a new named-pipe server intended to expose braille output/input to external processes.

Changes:

  • Add braille.BrailleMirror, braille.DirectBrailleWindow, plus registerMirror/unregisterMirror and injectGesture.
  • Add braillePipeServer named-pipe server for mirroring/display control and gesture injection.
  • Refactor _remoteClient follower-side braille forwarding to use BrailleMirror, and add unit tests for the new APIs.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/unit/test_braille/test_mirrorAndDirectWindow.py New unit tests covering mirror lifecycle, display forwarding, dimension capping, injectGesture, and DirectBrailleWindow behaviors.
source/braillePipeServer.py New named-pipe IPC server exposing mirror/direct-braille behaviors to external processes.
source/braille.py Adds the new public braille APIs and wires pipe-server initialization/termination into braille init/shutdown.
source/_remoteClient/session.py Refactors follower braille forwarding to use the new BrailleMirror API and adds a filter to force single-row output.
source/_remoteClient/localMachine.py Uses braille.injectGesture to execute incoming remote braille gestures (removes direct inputCore calls and old size negotiation code).

Comment on lines +309 to +319
# If braille is not yet initialised, queue registration.
if braille.handler is None:
pending_q.put(("mirror", handle, numCells))
return
session = _MirrorSession(handle, numCells)
elif mtype == "register_direct_braille":
hwnd = int(msg.get("hwnd", 0))
numCells = int(msg.get("numCells", 0))
if braille.handler is None:
pending_q.put(("direct", handle, hwnd, numCells))
return
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When braille.handler is None, the connection handle is queued in pending_q and the function returns, but the finally block still runs and closes the handle. This leaves a closed/invalid handle in the pending queue and will immediately disconnect the client. If you keep the pending-registration path, ensure the handle is not closed in this case (or duplicate the handle for queuing).

Copilot uses AI. Check for mistakes.
Comment on lines +226 to +271
def __init__(self, handle: ctypes.wintypes.HANDLE, numCells: int) -> None:
self._numCells = numCells
self._writer = _AsyncWriter(handle)
braille.registerMirror(self)
braille.displaySizeChanged.register(self._handleDisplaySizeChanged)
if braille.handler:
self._writer.send({"type": "display_size", "numCols": braille.handler.displayDimensions.numCols})

def numCells(self) -> int:
return self._numCells

def display(self, cells: list) -> None:
self._writer.send({"type": "display", "cells": cells})

def _handleDisplaySizeChanged(self, displaySize: int, numRows: int, numCols: int) -> None:
self._writer.send({"type": "display_size", "numCols": numCols})

def handleMessage(self, msg: dict) -> None:
mtype = msg.get("type")
if mtype == "inject_gesture":
gesture = _PipeBrailleGesture(
source=msg.get("source", ""),
model=msg.get("model", ""),
id_=msg.get("id", ""),
routingIndex=msg.get("routingIndex"),
dots=msg.get("dots", 0),
space=msg.get("space", False),
)
wx.CallAfter(braille.injectGesture, gesture)
else:
log.debug(f"braillePipeServer: unexpected mirror message type {mtype!r}")

def close(self) -> None:
braille.displaySizeChanged.unregister(self._handleDisplaySizeChanged)
braille.unregisterMirror(self)
self._writer.stop()


class _DirectSession(braille.DirectBrailleWindow):
"""DirectBrailleWindow that forwards gestures over the pipe."""

def __init__(self, handle: ctypes.wintypes.HANDLE, hwnd: int, numCells: int) -> None:
super().__init__(hwnd=hwnd, numCells=numCells)
self._writer = _AsyncWriter(handle)
self.activate()

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_MirrorSession/_DirectSession are constructed on per-connection threads, but they call into braille APIs that mutate global extension points (registerMirror, displaySizeChanged.register, DirectBrailleWindow.activate/deactivate). Those extension point handler lists are used on the main thread during braille rendering/input, so registering/unregistering from worker threads risks races. Consider marshaling these registrations/unregistrations to the main thread (e.g. via wxCallOnMain / wx.CallAfter + synchronization) and ensure session.close also runs the unregister steps on the main thread.

Copilot uses AI. Check for mistakes.
Comment thread source/braille.py
def _isForeground(self) -> bool:
"""Return True if our registered window is currently in the foreground."""
fg = winUser.getForegroundWindow()
return fg == self._hwnd or winUser.isDescendantWindow(self._hwnd, fg)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DirectBrailleWindow._isForeground passes arguments to winUser.isDescendantWindow in the wrong order. isDescendantWindow expects (parentHwnd, childHwnd), and the common pattern elsewhere is isDescendantWindow(fg, windowHandle). As written, descendant windows of the foreground window won’t be recognized, so direct braille mode may not activate correctly when the target window is inside the foreground root. Swap the arguments so the foreground window is treated as the parent.

Suggested change
return fg == self._hwnd or winUser.isDescendantWindow(self._hwnd, fg)
return fg == self._hwnd or winUser.isDescendantWindow(fg, self._hwnd)

Copilot uses AI. Check for mistakes.
Comment thread source/braille.py
Comment on lines +4006 to +4008
def _mirrorPreWriteCells(cells: List[int], **kwargs) -> None:
for mirror in _registeredMirrors:
mirror.display(cells)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_mirrorPreWriteCells forwards the mutable cells list directly to each mirror. Because this hook runs before the physical display write, any mirror that mutates the list (even accidentally) will change what NVDA outputs to the real display, which contradicts the expectation that a mirror is passive. Consider passing an immutable snapshot (e.g., a copy) to mirrors so they can’t affect the main output.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +12
"""Named-pipe IPC server that exposes the Braille Mirror and Direct Braille Window APIs to external processes.

Two pipe names are used:

* ``\\\\.\\pipe\\screen_reader_braille`` – normal desktop instance
* ``\\\\.\\pipe\\screen_reader_braille_secure`` – secure desktop instance

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pipe names documented here (screen_reader_braille / screen_reader_braille_secure) don’t match the PR description / intended API names (e.g. \.\pipe\nvda_braille / \.\pipe\nvda_braille_secure). This mismatch will confuse integrators and can lead to clients connecting to the wrong pipe. Align the docstring (and/or the actual pipeName constants) with the agreed public pipe names.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +35
_kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
_advapi32 = ctypes.windll.advapi32 # type: ignore[attr-defined]

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module calls Win32 APIs via ctypes.windll without setting argtypes/restype. On 64-bit Python, HANDLE-returning functions (e.g. CreateNamedPipeW/CreateFileW) default to a 32-bit c_int restype, which can truncate handles and cause invalid handle usage/CloseHandle on the wrong value. Please define prototypes (restype=wintypes.HANDLE / BOOL etc.) for the kernel32/advapi32 functions you use, or use existing winBindings wrappers where available.

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +142
def _writeAll(handle: ctypes.wintypes.HANDLE, data: bytes) -> bool:
"""Write all of *data* to *handle*. Return False on pipe break."""
offset = 0
while offset < len(data):
written = ctypes.wintypes.DWORD(0)
ok = _kernel32.WriteFile(
handle,
ctypes.c_char_p(data[offset:]),
len(data) - offset,
ctypes.byref(written),
None,
)
if not ok or written.value == 0:
return False
offset += written.value
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_writeAll passes data to WriteFile using ctypes.c_char_p(data[offset:]). These frames include the 4-byte length prefix and can contain embedded NUL bytes, and c_char_p is intended for NUL-terminated strings. Use a raw buffer/pointer type (e.g. create_string_buffer / (c_char * n) / c_void_p) to avoid string semantics and to keep the buffer alive for the duration of the call.

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +161
def _recvMessage(handle: ctypes.wintypes.HANDLE) -> Optional[dict]:
header = _readExact(handle, 4)
if header is None:
return None
(length,) = struct.unpack("<I", header)
body = _readExact(handle, length)
if body is None:
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_recvMessage trusts the 32-bit length prefix and allocates a buffer of that size with no upper bound. A malicious/buggy client can request an extremely large length, causing memory exhaustion or very long blocking reads. Add a reasonable maximum frame size (and reject/close the connection when exceeded) before allocating/reading the body.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,480 @@
# A part of NonVisual Desktop Access (NVDA)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please make sure to use the headers from here

Comment on lines +33 to +34
_kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
_advapi32 = ctypes.windll.advapi32 # type: ignore[attr-defined]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please make sure to use bindings from winBindings e.g. source\winBindings\kernel32.py rather than the windll directly

_kernel32.CloseHandle(handle)


_server: Optional[_PipeServer] = None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please make sure to use modern types e.g.

Suggested change
_server: Optional[_PipeServer] = None
_server: _PipeServer | None = None

Comment thread source/braille.py
inputCore.registerGestureSource("br", BrailleDisplayGesture)


class BrailleMirror:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be avoiding adding substantial new code to source/braille.py (See #12772)
Could you please move the added classes and functions to a new file (e.g. source/_brailleMirror.py)?

@seanbudd seanbudd marked this pull request as draft April 28, 2026 02:39
@seanbudd
Copy link
Copy Markdown
Member

Hi @trypsynth - I've performed a preliminary review, not a detailed on yet.
Could you please also make sure the check "check types with pyright" is also passing before marking as ready for review again?

@seanbudd seanbudd added the conceptApproved Similar 'triaged' for issues, PR accepted in theory, implementation needs review. label Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conceptApproved Similar 'triaged' for issues, PR accepted in theory, implementation needs review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposed NVDA Core APIs: Braille Mirror and Direct Braille

4 participants