Skip to content
This repository was archived by the owner on Mar 20, 2025. It is now read-only.

Enable modification of NVDA settings in automation #14

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bfba2b6
Centralize installation logic & reduce admin reqs
jugglinmike Dec 15, 2021
bd2abd4
PARTIAL: Port uninstall logic to C++
jugglinmike Dec 16, 2021
bb1bf3b
PARTIAL: Continue porting uninstall logic to C++
jugglinmike Dec 17, 2021
b78c844
Remove directory during un-install script
jugglinmike Dec 20, 2021
8af6517
Complete re-implementation
jugglinmike Dec 21, 2021
599a05b
Report operation result
jugglinmike Dec 21, 2021
5394e33
Simplify types
jugglinmike Dec 21, 2021
38e88f6
Remove unused code
jugglinmike Dec 21, 2021
32ad8ec
Remove reference to non-existent directory
jugglinmike Dec 21, 2021
b57956b
Remove unused Node.js package
jugglinmike Dec 22, 2021
9bca7ab
Define enum to avoid boolean trap and fix bug
jugglinmike Dec 29, 2021
7e70fea
PARTIAL Introduce NVDA add-on for config mgmt
jugglinmike Dec 23, 2021
6fd5ed3
Complete initial implementation
jugglinmike Dec 28, 2021
2144024
PARTIAL Integrate NVDA plugin and at-driver CLI
jugglinmike Dec 29, 2021
04b1525
Relocate NVDA plugin installation logic
jugglinmike Dec 29, 2021
61cfc6e
Centralize definition of nvda-addon's default port
jugglinmike Dec 29, 2021
9a70ddd
Make the TCP port of the NVDA add-on configurable
jugglinmike Dec 29, 2021
ceef813
Improve structure of NVDA add-on's Python modules
jugglinmike Dec 29, 2021
b56987f
Remove debugging code
jugglinmike Dec 29, 2021
e518271
Reorganize code for NVDA add-on
jugglinmike Dec 29, 2021
895df18
Reorganize source files for at-driver
jugglinmike Dec 29, 2021
fc29d28
Force WebSocket clients to specify AT under test
jugglinmike Dec 30, 2021
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,5 @@ FakesAssemblies/
# Custom ignores
gallery.xml
project.lock.json

*.pyc
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ running locally on a Microsoft Windows system.
3. Configure any screen reader to use the synthesizer named "Microsoft Speech
API version 5" and the text-to-speech voice named "Bocoup Automation Voice."

4. Use any WebSocket client to connect to the server specifying
`v1.aria-at.bocoup.com` as [the
4. Use any WebSocket client to connect to the server. Specify the screen reader
you'd like to test using the `at` query string parameter (e.g. `at=nvda`).
Specify `v1.aria-at.bocoup.com` as [the WebSocket
sub-protocol](https://datatracker.ietf.org/doc/html/rfc6455#section-1.9).
The protocol is described below. (The server will print protocol messages to
its standard error stream for diagnostic purposes only. Neither the format
Expand Down Expand Up @@ -81,6 +82,13 @@ interface ReleaseKeyCommand {
params: [string];
}

interface ConfigureCommand {
type: 'command';
id: number;
name: 'configure';
params: [object];
}

interface SuccessResponse {
type: 'response';
id: number;
Expand Down
2 changes: 1 addition & 1 deletion bin/at-driver
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/usr/bin/env node
require('../lib/cli')(process);
require('../lib/at-driver/cli')(process);
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
const { WebSocketServer } = require('ws');
const robotjs = require('robotjs');

const postJSON = require('./post-json');
const DEFAULT_AT_PORT = process.env.NVDA_CONFIGURATION_SERVER_PORT ||
require('../shared/default-at-configuration-port.json');
const SUB_PROTOCOL = 'v1.aria-at.bocoup.com';
const HTTP_UNPROCESSABLE_ENTITY = 422;
const supportedAts = ['nvda'];

const broadcast = (server, message) => {
const packedMessage = JSON.stringify(message);
Expand All @@ -18,10 +23,11 @@ const broadcast = (server, message) => {
});
};

const onConnection = (websocket) => {
const onConnection = (websocket, request) => {
const send = (value) => websocket.send(JSON.stringify(value));
const {at, port} = validateRequest(request);

websocket.on('message', (data) => {
websocket.on('message', async (data) => {
let parsed;
const type = 'response';
try {
Expand Down Expand Up @@ -52,6 +58,8 @@ const onConnection = (websocket) => {
robotjs.keyToggle(parsed.params[0], 'down');
} else if (parsed.name === 'releaseKey') {
robotjs.keyToggle(parsed.params[0], 'up');
} else if (parsed.name === 'configure') {
await postJSON(port, parsed.params[0]);
} else {
throw new Error(`Unrecognized command name: "${data}".`);
}
Expand All @@ -63,6 +71,51 @@ const onConnection = (websocket) => {
});
};

/**
* Determine if the server can initiate a WebSocket session to satisfy a given
* HTTP request.
*
* @param {http.IncomingMesssage} - an object representing an HTTP request
* which has been submitted as a "handshake"
* to initiate a WebSocket session
* @returns {Error|object} if the request can be satisfied, an object defining
* the requested assistive technology and TCP port if
* the request can be satisfied; if the request cannot
* be satisfied, an Error instance describing the
* reason
*/
const validateRequest = (request) => {
const subProtocol = request.headers['sec-websocket-protocol'];

if (subProtocol !== SUB_PROTOCOL) {
return new Error(`Unrecognized sub-protocol: ${subProtocol}"`);
}

const {searchParams} = new URL(request.url, `http://${request.headers.host}`);
const at = searchParams.get('at');

if (!at) {
return new Error(
'An assistive technology must be specified via the "at" URL query string parameter.'
);
}

if (!supportedAts.includes(at)) {
return new Error(`Unrecognized assistive technology: "${at}".`);
}

const port = searchParams.has('port') ?
parseInt(searchParams.get('port'), 10) : DEFAULT_AT_PORT;

if (Number.isNaN(port)) {
return new Error(
`Invalid value for "port" URL query string parameter: "${searchParams.get('port')}".`
);
}

return {at, port};
};

/**
* Create a server which communicates with external clients using the WebSocket
* protocol described in the project README.md file.
Expand All @@ -77,8 +130,12 @@ const onConnection = (websocket) => {
module.exports = async function createWebSocketServer(port) {
const server = new WebSocketServer({
clientTracking: true,
verifyClient({req}) {
return req.headers['sec-websocket-protocol'] === SUB_PROTOCOL;
verifyClient({req}, done) {
const result = validateRequest(req);
if (result instanceof Error) {
return done(false, HTTP_UNPROCESSABLE_ENTITY, result.message);
}
return done(true);
},
port,
});
Expand Down
47 changes: 47 additions & 0 deletions lib/at-driver/post-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict';
const http = require('http');

/**
* Transmit JSON-formatted UTF-8 encoded data via an HTTP POST request.
*
* @param {number} port - the TCP port on which to send the data
* @param {object} body - the data to send
*/
module.exports = function postJSON(port, body) {
return new Promise((resolve, reject) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the nesting can be reduced some to be easier to read.

module.exports = async function postJSON(port, body) {
  const response = await new Promise((resolve, reject) => {
    const postData = JSON.stringify(body);
    const request = http.request(
      {
        hostname: "localhost",
        method: "POST",
        port,
        headers: {
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(postData),
        },
      },
      resolve
    );

    request.on("error", reject);
    request.write(postData);
    request.end();
  });

  const responseBody = await new Promise((resolve) => {
    let responseBody = "";
    response.setEncoding("utf-8");
    response.on("data", (chunk) => (responseBody += chunk));
    response.on("end", () => {
      resolve(responseBody);
    });
  });

  if (!response.complete) {
    throw new Error("HTTP response interrupted");
  }

  if (response.statusCode >= 300) {
    throw new Error(responseBody);
  }
};

const postData = JSON.stringify(body);
const request = http.request(
{
hostname: 'localhost',
method: 'POST',
port,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
},
(response) => {
let responseBody = '';
response.setEncoding('utf-8');
response.on('data', (chunk) => responseBody += chunk);
response.on('end', () => {
if (!response.complete) {
reject(new Error('HTTP response interrupted'));
return;
}

if (response.statusCode >= 300) {
reject(new Error(responseBody));
return;
}

resolve();
});
}
);

request.on('error', reject);
request.write(postData);
request.end();
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from threading import Thread

import addonHandler
import config
import globalPluginHandler

from .host_location import HOST_NAME, HOST_PORT
from .config_server import ConfigServer, ConfigHandler

try:
ADDON_NAME = addonHandler.getCodeAddon().name
except addonHandler.AddonError:
ADDON_NAME = 'scratch-nvda-config-server'

def update_dictlike(dictlike, values):
for key, value in values.items():
if hasattr(dictlike[key], '__setitem__'):
update_dictlike(dictlike[key], value)
else:
dictlike[key] = value

class GlobalPlugin(globalPluginHandler.GlobalPlugin):
def __init__(self, *args, **kwargs):
super(GlobalPlugin, self).__init__(*args, **kwargs)
def on_get():
return self.dict()
def on_post(body):
self.update(body)
self.server = ConfigServer(on_get, on_post, (HOST_NAME, HOST_PORT), ConfigHandler)
self.server_thread = Thread(target=self.server.serve_forever, daemon=True)
self.server_thread.start()
self.profile_to_restore = config.conf.profiles[-1].name

def terminate(self, *args, **kwargs):
self.server.shutdown()
self.server.server_close()
self.server_thread.join()
config.conf.manualActivateProfile(self.profile_to_restore)
super().terminate(*args, **kwargs)

@property
def config(self):
profile = config.conf.profiles[-1].name
if profile != ADDON_NAME:
self.profile_to_restore = profile
if ADDON_NAME not in config.conf.listProfiles():
config.conf.createProfile(ADDON_NAME)
config.conf.manualActivateProfile(ADDON_NAME)
return config.conf

def dict(self):
return self.config.dict()

def update(self, values):
update_dictlike(self.config, values)
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import sys

class ConfigServer(HTTPServer):
def __init__(self, on_get, on_post, *args, **kwargs):
super(ConfigServer, self).__init__(*args, **kwargs)
self.on_get = on_get
self.on_post = on_post

class ConfigHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
body = bytes(json.dumps(self.server.on_get()), 'utf-8')
except:
self.send_response(500)
self.end_headers()
self.wfile.write(bytes('Error: "{}"'.format(sys.exc_info()[1]), 'utf-8'))
return

self.send_response(200)
self.send_header('Content-type', 'text/json')
self.end_headers()
self.wfile.write(body)

def do_POST(self):
try:
content_length = int(self.headers['Content-Length'])
body = json.loads(self.rfile.read(content_length))
self.server.on_post(body)
except:
self.send_response(500)
self.end_headers()
self.wfile.write(bytes('Error: "{}"'.format(sys.exc_info()[1]), 'utf-8'))
return

self.send_response(200)
self.end_headers()
self.wfile.write('ok')
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import json
import os

HOST_NAME = 'localhost'

try:
HOST_PORT = int(os.environ['NVDA_CONFIGURATION_SERVER_PORT'])
except KeyError:
# The installation procedure inserts this file from the project's "shared"
# directory.
filename = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'..',
'..',
'shared',
'default-at-configuration-port.json'
)

with open(filename, 'r') as handle:
HOST_PORT = json.loads(handle.read())
8 changes: 8 additions & 0 deletions lib/nvda-configuration-server/manifest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name = configuration-server
summary = "Interact with NVDA's configuration via a local HTTP server"
description = """This add-on exposes the configuration of the running NVDA process via a public interface so that external tools can modify that configuration on-the-fly. The TCP port in use by the HTTP server can be specified via the NVDA_CONFIGURATION_SERVER_PORT environment variable."""
author = "Bocoup LLC"
url = https://bocoup.com
version = 0.0.1
minimumNVDAVersion = 2021.3
lastTestedNVDAVersion = 2021.3
1 change: 1 addition & 0 deletions lib/shared/default-at-configuration-port.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
7658
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the file just the port?

5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
"at-driver": "./bin/at-driver"
},
"scripts": {
"postinstall": "node scripts/install.js -- install",
"postuninstall": "node scripts/install.js -- uninstall",
"postinstall": "scripts\\install.bat",
"postuninstall": "scripts\\uninstall.bat",
"test": "mocha --ui tdd test"
},
"files": [
"lib",
"Release/AutomationTtsEngine.dll",
"Release/MakeVoice.exe",
"Release/Vocalizer.exe",
"scripts"
],
Expand All @@ -24,7 +25,6 @@
},
"dependencies": {
"robotjs": "^0.6.0",
"sudo-prompt": "^9.2.1",
"ws": "^8.2.3",
"yargs": "^17.2.1"
}
Expand Down
28 changes: 28 additions & 0 deletions scripts/install.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.\Release\MakeVoice.exe || exit /b

:: Install the project's custom NVDA add-on.
::
:: This operation is somewhat fragile (in that it may be broken by future
:: releases of NVDA) because it assumes the location and format of the screen
:: reader's "add on" directory. Although it would be preferable to first
:: "package" the add-on and then install it using the same procedure as an
:: end-user, that approach requires manual interaction with a modal dialog and
:: is therefore inappropriate for the needs of this project.

set destination=%USERPROFILE%\AppData\Roaming\nvda\addons\nvda-configuration-server

IF EXIST %destination% rmdir /Q /S %destination% || exit /b

:: In some environments, the "mkdir" command may be capable of creating
:: non-existent intermediate directories in the input path. This is dependent
:: on the presence of "Command Extensions". Create each directory individually
:: to support environments where Command Extensions are not enabled.
mkdir %USERPROFILE%\AppData
mkdir %USERPROFILE%\AppData\Roaming
mkdir %USERPROFILE%\AppData\Roaming\nvda
mkdir %USERPROFILE%\AppData\Roaming\nvda\addons
mkdir %destination% || exit /b

xcopy /E /Y .\lib\nvda-configuration-server %destination% || exit /b

xcopy /E /Y .\lib\shared %destination%\shared\ || exit /b
Loading