The Accessible Trading Terminal separates business logic from platform-specific hardware drivers, and ships through two hosts over one component library: .NET 10 MAUI Blazor Hybrid on Windows, Android, macOS and iOS, and ASP.NET Core Blazor Server (AccessibleTrader.WebHost) on Linux and anything browser-reachable. The tables in section 1 describe the MAUI drivers; the WebHost's equivalents are in §6.1, which is also the path the public deployment runs and the one most users install.
1. Architectural Layers
Core Logic (Agnostic)
Located in AccessibleTrader.Core. Contains the math, technical indicators, and Orchestrators (DataOrchestrator, IndicatorOrchestrator, MarketOrchestrator, DataOrchestrationService). Fully platform-agnostic.
- Audio Engine: Pure C# DSP for waveform generation and mixing. Generates raw
float[]buffers consumed by platform drivers. - Shortcut Manager: Data-driven logic within
ShortcutManagerfor resolving physical keys to semanticSystemCommandvalues. - Accessibility Engines: Logic for navigation, viewport calculations, and feedback string construction.
Platform Drivers & UI (Implementation)
Located in AccessibleTrader.BlazorClient/Services and AccessibleTrader.BlazorClient/Platforms/*. These implement agnostic interfaces to bridge Core to the OS.
| System | Interface | Windows | Android | macOS | iOS |
|---|---|---|---|---|---|
| Audio | IAudioDriver |
BlazorAudioDriver (WASAPI via NAudio) |
BlazorAudioDriver (AudioTrack PCM-Float push loop) |
BlazorAudioDriver (AVAudioEngine + AVAudioSourceNode) |
BlazorAudioDriver (AVAudioEngine + AVAudioSourceNode) |
| Speech | ISpeechManager |
BlazorSpeechManager (ARIA live regions) |
BlazorSpeechManager (ARIA live regions) |
BlazorSpeechManager (ARIA live regions) |
BlazorSpeechManager (ARIA live regions) |
| Input | IInputService |
GlobalInputService (JS interop) |
MainActivity.DispatchKeyEvent |
KeyboardPageHandler (PressesBegan) |
KeyboardPageHandler (PressesBegan) |
| Secure Storage | ISecureStorageService / IPluginSecureStorage |
MauiSecureStorageService (DPAPI) |
MauiSecureStorageService (KeyStore) |
MauiSecureStorageService (Keychain) |
MauiSecureStorageService (Keychain) |
| Paths | IPlatformPathService |
MauiPathService |
MauiPathService |
MauiPathService |
MauiPathService |
| Script Sandbox | IScriptWorkerLauncher |
WindowsAppContainerLauncher (AppContainer + STARTUPINFOEX) |
AndroidIsolatedProcessLauncher ([Service(IsolatedProcess=true)] bound service) |
MacSandboxExecLauncher (sandbox-exec deny-default profile) |
refused — ScriptingNotSupportedOnPlatformException at compile time |
iOS and Mac Catalyst do not merely lack a launcher; they refuse to compile a script at all, loudly, rather than falling back to running it in-process. Mac Catalyst joined iOS there because its self-contained build cannot reference the
net10.0ScriptWorker. On Linux the launcher isLinuxBwrapLauncher(bubblewrap) — see §5 and §6.1.
2. Rendering Architecture
The UI is built using Blazor running inside a MAUI WebView.
- Charts: Rendered via SkiaSharp on a native MAUI
SKCanvasViewat Grid layer 0 inMainPage.xaml. TheBlazorWebViewoverlays at layer 1 (transparent).SkiaSharp.Views.Blazoris NOT used. - UI Chrome: Blazor Razor components — toolbar, modals, status indicators — rendered in the transparent Blazor WebView overlay.
- Theming: CSS-based (Dark Mode priority). CSS variables control colors; accessible high-contrast ratios enforced.
3. Audio Architecture
The AudioEngine generates raw floating-point audio buffers.
- The engine is fully decoupled from any audio library — platform drivers handle the "final mile" of pushing buffers to hardware output.
- Windows (WASAPI):
BlazorAudioDriverpushes buffers to the WASAPI output device via NAudio.Wasapi'sWasapiOut. - Android (
AudioTrack):AudioTrack.Builderconfigured withChannelOut.Stereo,Encoding.PcmFloat,AudioTrackMode.Stream; dedicated background thread writes buffers via_audioTrack.Write(...). - iOS / macCatalyst (
AVAudioEngine):AVAudioEngine+AVAudioSourceNoderender callback. The source node pull function copies from the engine's float buffer into theAudioBufferListthe node hands us.
4. Keyboard Input Architecture
Input is normalized via the IInputService abstraction on every platform.
- Windows: MAUI captures raw hardware events and forwards them through the Blazor context via
[JSInvokable]methods inGlobalInputService. - Android:
MainActivity.DispatchKeyEventintercepts hardware-keyboard events (incl. Bluetooth keyboards) and routes them toIInputService. - macOS / iOS:
KeyboardPageHandleris a customPageHandlerregistered inMauiProgram.cs(handlers.AddHandler<ContentPage, KeyboardPageHandler>()). AUIViewControllersubclass overridesPressesBegan(NSSet<UIPress>, UIPressesEvent)and forwardsUIKeypresses toIInputService. ShortcutManagerresolves key combos against aShortcutProfileto identifySystemCommandvalues.CommandDispatcherroutesSystemCommandvalues toNavigationEngine,WorkspaceStore, or EventBus.
5. Script Sandboxing Architecture
User-compiled Roslyn indicators and strategies run in an out-of-process worker with OS-enforced isolation on every supported desktop/mobile platform. See SANDBOX_DESIGN.md for the full IPC contract, resource quotas, and threat model.
- Host side:
OutOfProcessScriptHostsupervisor — 5 s Calculate timeout, 10 s LoadAssembly timeout, 256 MBWorkingSet64quota polled every 2 s, kill-on-overage viaProcess.Kill(entireProcessTree: true), graceful Shutdown with 1 s grace on disposal. - Worker side:
AccessibleTrader.ScriptWorker(stdio entry-point) reuses the sharedWorkerDispatcherfromAccessibleTrader.ScriptSandbox. - Per-OS launcher: automatic selection via
RoslynScriptingService.CreateDefaultLauncher(). On dev-box ACL gaps Windows falls back toDefaultProcessLauncher(still a separate process, just without AppContainer).
6. Platform Compatibility Matrix
| Feature | Windows | Android | macOS | iOS | Linux (WebHost) |
|---|---|---|---|---|---|
| Host shell | MAUI Blazor Hybrid | MAUI Blazor Hybrid | MAUI Blazor Hybrid | MAUI Blazor Hybrid | ASP.NET Core Blazor Server (browser) |
| Chart Rendering | |||||
Native SKCanvasView overlay |
|||||
Native SKCanvasView overlay |
|||||
Native SKCanvasView overlay |
|||||
Native SKCanvasView overlay |
|||||
Server-side Skia → base64 PNG → <img> (~10 fps throttled) |
|||||
| Chart Sonification | |||||
Same AudioEngine → pw-cat / pacat / aplay |
|||||
| Speech / Screen Reader | |||||
| NVDA/Narrator + ARIA | |||||
| TalkBack via ARIA | |||||
| VoiceOver via ARIA | |||||
| VoiceOver via ARIA | |||||
Orca via D-Bus PresentMessage → respects voxin / SpeechDispatcher voice |
|||||
| Market Data | |||||
| (same plugins) | |||||
| Keyboard Navigation | |||||
Same JS bridge; Ctrl+Shift+letter drawing chords remapped to Alt+Shift+letter (Firefox reserves the originals) |
|||||
| Mouse / Drawing-tool placement | |||||
| Native pointer events | n/a | ||||
| Native pointer events | n/a | ||||
JS registerMouseHandler on chart-interact-zone → (x, y, w, h) → anchor placement (L4-B pinned by tests) |
|||||
| Audio Output | |||||
| (WASAPI) | |||||
(AudioTrack) |
|||||
(AVAudioEngine) |
|||||
(AVAudioEngine) |
|||||
| pw-cat (PipeWire) → pacat (PulseAudio) → aplay (ALSA), picked by file-existence probe at startup | |||||
| Script Sandbox (OS-enforced) | |||||
| (AppContainer) | |||||
| (isolatedProcess) | |||||
| (sandbox-exec) | ⏸ (deferred) | ||||
(bwrap — L5; falls back to process-only if bubblewrap not installed) |
|||||
| Secure Storage | |||||
| (DPAPI) | |||||
| (KeyStore) | |||||
| (Keychain) | |||||
| (Keychain) | |||||
(ASP.NET Core DataProtection, encrypted-at-rest in {XDG_DATA_HOME}/AccessibleTrader/secrets/) |
|||||
| System-tray applet / background alerts | |||||
MAUI close-to-tray (default on, pending Windows verify); WebHost Shell_NotifyIcon |
❌ n/a | ⚠️ WebHost menu actions only — native icon is a future MAUI Mac feature | ❌ n/a | ||
| WebHost StatusNotifier / DBusMenu (Orca-navigable, verified); local Full mode only | |||||
| Tactile Display | |||||
| (all Dot Pad models — X + 2nd-gen — see §7) | ❌ | ❌ | ❌ | ❌ (vendor Linux SDK is text-only / 20-cell — see §7) |
6.1 The Linux WebHost path
AccessibleTrader.WebHost is an ASP.NET Core Blazor Server project (net10.0)
that serves the existing AccessibleTrader.BlazorClient.Components RCL
without going through MAUI. MAUI has no Linux head, so Linux is the
WebHost's primary target; the same project is also the deploy target for
the public-website chart demo (any OS that runs a modern browser can be
the client).
Two RCL changes were required, both gated on
IRuntimePlatform.IsBrowserHost:
IRuntimePlatformgained a default-implementationbool IsBrowserHost => false;. MAUI'sMauiRuntimePlatforminherits the default and getsfalseautomatically — no source edit needed in the MAUI head.WebHostRuntimePlatformoverrides totrue.ChartArea.razorrenders an inline<img>chart surface only whenIsBrowserHostis true; the MAUI path keeps using its nativeSKCanvasViewoverlay declared inMainPage.xaml.
Behaviour under MAUI is bit-for-bit unchanged. The WebHost runs locally
(dotnet run --project AccessibleTrader.WebHost, opens
http://localhost:5145 in the user's default browser via xdg-open /
open / start) and never requires a MAUI workload to be installed.
7. Tactile Display Support
All Dot Pad models are supported — the Dot Pad X (newest) and the second generation —
because they share the same DotPadSDK-3.0.0 native graphics ABI, so one driver binds
across the family:
| Device | Status | Notes |
|---|---|---|
| Dot Pad X (30 × 10 graphic cells + 20-cell strip, newest model) | ||
| Supported | Same DotPadSDK-3.0.0 ABI as the 2nd-gen; binds without code changes. Connection via USB-Serial. On-device confirmation pending (see 2nd-gen). | |
| Dot Pad 2nd-gen (30 × 10 graphic cells + 20-cell strip) | ||
| Tested on-device | Connection via USB-Serial; SDK uses DOT_PAD_CONNECT_SERIAL. |
|
| APH Monarch | ❌ Not implemented | Requires a different SDK (Dot Inc proprietary, vendor-restricted). |
| Linux (any device) | ❌ Vendor SDK gap | Verified 2026-05-16 against dotincorp/dotpad-sdk-guide and dotincorp/dotpad-sample-code: the official Linux SDK is v1.0.0, ships only a text-strip API (displayTextData / setBrailleLanguage), supports 20-cell devices only, and exposes no graphic display API at all. Sample app confirms /dev/ttyUSB0 text-only. Per the all-or-nothing tactile rule, Linux uses NullDotPadNative until Dot Inc publishes a Linux 3.0.0 SDK with graphic parity. Track upstream at the dotincorp repos. |
Wiring summary
- Driver:
AccessibleTrader.Core/Services/Accessibility/Dotpad/DotpadTactileDriver.cs— Windows-only viaNativeLibrary.TryLoadagainstDotPadSDK-3.0.0.dll. Falls back toNullDotPadNativeon Android / iOS / macCatalyst so the rest of the app still builds and runs. - Coordinator:
AccessibleTrader.Core/Services/Accessibility/TactileCanvasCoordinator.cs— composes a two-pane 50/50 graphic + 20-cell strip fromWorkspaceStateon every navigation event, and routes the device's F1-F4 + Pan keys through the existing speech / command pipeline. - Calibrator:
tools/DotPadCalibrator/Program.cs— standalone CLI for bit-order, cell-index, and stripe probing on a physical device. - Diagnostics:
dotpad-diagnose.bat+dotpad-diagnose.ps1at the repo root — captures COM-port + USB inventory + Dot Pad device info to a log file. Useful when the driver can't find the device on first plug-in.
SDK installation
If you have a released zip, there is nothing to do. From the 2.12.0 re-cut the SDK travels
in the Windows download, fetched by release.yml from Dot Inc's own public repository and
pinned by SHA-256. This section is about building from source.
The Dot Inc SDK is not committed to the repo (~296MB for Windows 3.0.0 alone, vendor-licensed binaries). To enable Dot Pad support in a source build:
- Take
Windows/3.0.0/from https://github.com/dotincorp/dotpad-sdk-guide (or the Dot Inc developer portal). Note the path:Windows/3.0.0, notWindows/dotpad-3.0.0— the repo's directory is named for the version, ours is named for the product, and a URL built from the local name 404s. - Place it at
dotpad-sdk/Windows/dotpad-3.0.0/relative to the repo root. The build expectsDotPadSDK-3.0.0.dll,TTBEngine.dll,Mecab.dll,jsoncpp.dll,liblouis.dll,mecabrcand the extractedtables/directory there. - Build normally.
CopyDotPadSdkWindowsstages everything alongside the app binary at build time; the<None>items withCopyToPublishDirectoryare what carry it into a publish.
What ships, and what does not. Eighteen megabytes of the SDK ship; ipadic/ — 187MB of
MeCab's Japanese dictionary — does not. Three pieces of evidence, all from the binaries:
WindowsDotPadNativebinds exports onDotPadSDK-3.0.0.dllonly, and never touches TTBEngine or MeCab.DotPadSDK-3.0.0.dllhas no static dependency on TTBEngine, MeCab, liblouis or jsoncpp — its PE import table names only the VC++ runtime. ItLoadLibrarys the rest, and says so:[OK] liblouis.dll loaded from:/DOT_ERROR_LIBLOUIS_DLL_COULD_NOT_LOAD.- MeCab is reached only from
TTBEngine'smecab_new2("-u ./ipadic/user.dic"), and there is nouser.dicanywhere in the 187MBipadic/directory — the vendor does not ship one. Both that path andmecabrc'sdicdir = ./ipadicare relative to the working directory, not the install, so they would miss regardless.
If a real Dot Pad ever fails to initialise with everything else present, ipadic/ is the first
thing to add back. The 14MB of LibLouis tables/ do ship in full: the SDK points
lou_setDataPath at a \tables directory beside itself, names 35 specific .ctb/.utb files,
and the tables include each other transitively, so trimming them breaks one braille code
silently for 13MB.
The VC++ runtime ships with the SDK. DotPadSDK-3.0.0.dll statically imports MSVCP140,
VCRUNTIME140 and VCRUNTIME140_1 — a .NET publish carries none of them. Without them the SDK
does not load on a machine that has no Visual Studio and no redistributable, and the Braille tab
looks exactly as healthy as it does when everything works. release.yml stages them from the
runner into vendor/vcruntime/; see that directory's README for a hand-built release.
An ordinary source build still runs WITHOUT the SDK present — WarnIfDotPadSdkMissing emits a
one-line MSBuild message and Dot Pad support is unavailable at runtime (DotpadTactileDriver
reports not-connected via the NullDotPadNative fallback). A RELEASE build does not. Under
-p:ReleasePublish=true the missing SDK is an error, because v2.12.0 shipped once with the
message printed in a green log that nobody read, telling users the SDK was in the download.
NVDA Controller Client (required for speech on the Windows desktop head)
This one is not optional in the way the Dot Pad SDK is. Without it the desktop head is effectively mute while the chart has focus, which is most of the time.
BlazorSpeechManager P/Invokes nvdaControllerClient64.dll to speak directly to NVDA. That path
matters here in a way it does not on the WebHost, because of how this head is assembled: the chart
is a native SkiaSharp canvas sitting on top of the BlazorWebView. When the user focuses the
chart, the screen reader is following focus onto a native control and is not reading the web
view's DOM at all — so the ARIA live region inside it, which is the fallback everywhere else,
announces to nobody. On the WebHost the entire surface is the DOM and the fallback works, which is
why the same build served over the web reads correctly through NVDA while the desktop client says
nothing.
Until 2026-09-21 nothing in the build staged this file: it was untracked by git, named by no
project file, and absent from every publish. A single copy had been dropped by hand into a
bin/Debug folder on 2026-03-03, so exactly one machine could speak and every fresh build and
every published install was silent. Found the first time the head was put in front of a screen
reader.
And the first fix for that did not reach the download either. The csproj items were correct,
but the DLL is gitignored and the items are Exists()-guarded, so the GitHub Actions runner —
which had no copy — built a perfectly valid mute release and said nothing. v2.12.0 was published
telling users the DLL was "in the download now" when it was in none of the six assets. From the
2.12.0 re-cut, release.yml fetches
nvda_<version>_controllerClient.zip from NV Access, pinned by SHA-256, a release build with no
controller client is a hard error, and scripts/verify_release_payloads.py refuses to
publish a zip that does not contain it. A release that ships mute is worse than a release that
fails.
JAWS
Nothing to install and nothing to configure. JAWS registers a COM automation object
(FreedomSci.JawsApi) when it installs, and the terminal speaks through it directly — the same
route it uses for NVDA, and for the same reason: the chart is a native canvas, so a reader
following focus onto it is not reading the web view's DOM and the live region cannot reach it.
No vendored binary ships for this and no build step stages one. On a machine without JAWS the object simply does not resolve and the terminal uses whatever else is available.
If both JAWS and NVDA are running, NVDA carries the speech. They are not normally both up; the order is a tie-break so that two readers cannot talk over each other.
If you have a released zip (no repo)
From the 2.12.0 re-cut you do not need to do any of this — the controller client is in the download. Everything below applies to 2.12.0-as-first-published and to every release before it, where the DLL has to be placed by hand.
The DLL is found by the default Windows DLL search order, which looks in the application's own folder first, so no rebuild is involved.
- Download
nvda_<version>_controllerClient.zipfrom https://github.com/nvaccess/nvda/releases (it is listed among the assets on any release). - Inside it, open the
x64folder and takenvdaControllerClient.dll. - Drop that file into the folder you unzipped the terminal into — the one containing
AccessibleTrader.BlazorClient.exe. Top level, beside the exe, not in a subfolder. - Restart the terminal.
If you are on 2.11.0 or earlier, rename it to
nvdaControllerClient64.dll. NV Access renamed this file at some point; current downloads shipnvdaControllerClient.dlland older ones shippednvdaControllerClient64.dll. Builds up to and including 2.11.0 ask for the old name only, so the current download sits beside the executable being ignored — the file is there and nothing is looking for that name. From 2.12.0 either name works, because the load is resolved against both.
There is no vendor/ folder in the zip and there should not be: vendor/nvda/ is a source-tree
staging location used at build time, not part of the shipped layout.
If you are building from source
- Download the same zip as above.
- Copy the x64
nvdaControllerClient.dllintovendor/nvda/relative to the repo root. (nvdaControllerClient64.dllis accepted there too, for anyone holding the older download.) It is gitignored — a third-party binary (NV Access, LGPL 2.1), vendored rather than committed for the same reason the Dot Pad SDK is. - Build normally. The DLL is declared as a content item with
CopyToPublishDirectory, so it reaches bothdotnet buildoutput anddotnet publishoutput — which is what puts it in a release zip. (The first cut of this staged it with aCopyinto$(OutDir), mirroringCopyDotPadSdkWindows. That reaches a build and not a publish, so it would have worked on a developer machine and shipped nothing.)
A build without it still succeeds, and WarnIfNvdaControllerMissing prints a high-importance
MSBuild message saying the result will be silent. At runtime the condition is reported too rather
than being left as an absence of sound: BlazorSpeechManager.OutputStatus answers
NvdaDirect / LiveRegion / Mute, a Mute terminal writes an error into the journal naming
this file, and every sentence it could not say is still journalled so nothing is lost.