From f0c7ec2bffc8332904d92c2aa5b44cd7b525725a Mon Sep 17 00:00:00 2001 From: "Michael \"Z\" Goddard" Date: Thu, 22 Feb 2024 11:48:01 -0500 Subject: [PATCH 1/4] add install and uninstall subcommands Add subcommands for installing text to speech extensions and other support software. --- README.md | 10 +++++--- lib/cli.js | 53 ++++++++++++++++++++++++++------------- lib/commands/install.js | 15 +++++++++++ lib/commands/uninstall.js | 15 +++++++++++ lib/install/macos.js | 9 +++++++ lib/install/win32.js | 26 +++++++++++++++++++ package.json | 2 -- 7 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 lib/commands/install.js create mode 100644 lib/commands/uninstall.js create mode 100644 lib/install/macos.js create mode 100644 lib/install/win32.js diff --git a/README.md b/README.md index 16019fd..3adebb0 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,13 @@ which is required by the automation voice. npm install -g @bocoup/windows-sapi-tts-engine-for-automation +2. Run the install command in a terminal: + + at-driver install + If prompted for system administration permission, grant permission. -2. Start the server by executing the following command in a terminal: +3. Start the server by executing the following command in a terminal: at-driver @@ -42,10 +46,10 @@ which is required by the automation voice. WebSocket server is listening for connections. The `--help` flag will cause the command to output advanced usage instructions (e.g. `at-driver --help`). -3. Configure any screen reader to use the synthesizer named "Microsoft Speech +4. 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. The protocol is described +5. Use any WebSocket client to connect to the server. The protocol is described below. (The server will print protocol messages to its standard error stream for diagnostic purposes only. Neither the format nor the availability of this output is guaranteed, making it inappropriate for external use.) diff --git a/lib/cli.js b/lib/cli.js index 7a12bd0..a845141 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -18,27 +18,46 @@ const DEFAULT_PORT = 4382; */ const log = (...args) => console.error(new Date().toISOString(), ...args); +/** + * @param {import('process')} process + */ module.exports = async process => { - const argv = await yargs(hideBin(process.argv)) - .option('port', { - coerce(string) { - if (!/^(0|[1-9][0-9]*)$/.test(string)) { - throw new TypeError( - `"port" option: expected a non-negative integer value but received "${string}"`, - ); - } - return Number(string); + await yargs(hideBin(process.argv)) + .commandDir('./commands') + .command({ + command: 'serve [port]', + // Alias the command as the default command. + aliases: ['$0'], + describe: 'Run at-driver server', + builder(yargs) { + return yargs.option('port', { + coerce(string) { + if (!/^(0|[1-9][0-9]*)$/.test(string)) { + throw new TypeError( + `"port" option: expected a non-negative integer value but received "${string}"`, + ); + } + return Number(string); + }, + default: DEFAULT_PORT, + describe: 'TCP port on which to listen for WebSocket connections', + // Do not use the `number` type provided by `yargs` because it tolerates + // JavaScript numeric literal forms which are likely typos in this + // context (e.g. `0xf` or `1e-0`). + type: 'string', + requiresArg: true, + }); }, - default: DEFAULT_PORT, - describe: 'TCP port on which to listen for WebSocket connections', - // Do not use the `number` type provided by `yargs` because it tolerates - // JavaScript numeric literal forms which are likely typos in this - // context (e.g. `0xf` or `1e-0`). - type: 'string', - requiresArg: true, + handler: serve, }) + .help() .parse(); +}; +/** + * @param {import('yargs').Arguments<{port: number}>} argv + */ +async function serve(argv) { const socketPath = await prepareSocketPath(); const [commandServer, voiceServer] = await Promise.all([ @@ -65,7 +84,7 @@ module.exports = async process => { voiceServer.on('error', error => { log(`error: ${error}`); }); -}; +} const prepareSocketPath = (exports.prepareSocketPath = async () => { if (process.platform === 'win32') { diff --git a/lib/commands/install.js b/lib/commands/install.js new file mode 100644 index 0000000..aaec388 --- /dev/null +++ b/lib/commands/install.js @@ -0,0 +1,15 @@ +'use strict'; + +const { loadOsModule } = require('../helpers/load-os-module'); + +module.exports = /** @type {import('yargs').CommandModule} */ ({ + command: 'install', + describe: 'Install text to speech extension and other support', + async handler() { + const installDelegate = loadOsModule('install', { + darwin: () => require('../install/macos'), + win32: () => require('../install/win32'), + }); + await installDelegate.install(); + }, +}); diff --git a/lib/commands/uninstall.js b/lib/commands/uninstall.js new file mode 100644 index 0000000..8764a2e --- /dev/null +++ b/lib/commands/uninstall.js @@ -0,0 +1,15 @@ +'use strict'; + +const { loadOsModule } = require('../helpers/load-os-module'); + +module.exports = /** @type {import('yargs').CommandModule} */ ({ + command: 'uninstall', + describe: 'Uninstall text to speech extension and other support', + async handler() { + const installDelegate = loadOsModule('install', { + darwin: () => require('../install/macos'), + win32: () => require('../install/win32'), + }); + await installDelegate.uninstall(); + }, +}); diff --git a/lib/install/macos.js b/lib/install/macos.js new file mode 100644 index 0000000..37c87c1 --- /dev/null +++ b/lib/install/macos.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.install = async function() { + throw new Error('macos install not implemented'); +}; + +exports.uninstall = async function() { + throw new Error('macos uninstall not implemented'); +}; diff --git a/lib/install/win32.js b/lib/install/win32.js new file mode 100644 index 0000000..9c962e8 --- /dev/null +++ b/lib/install/win32.js @@ -0,0 +1,26 @@ +'use strict'; + +const { exec: _exec } = require('child_process'); +const { resolve } = require('path'); +const { promisify } = require('util'); + +const exec = promisify(_exec); + +const MAKE_VOICE_EXE = 'MakeVoice.exe'; + +exports.install = async function () { + await exec(`${MAKE_VOICE_EXE}`, await getExecOptions()); +}; + +exports.uninstall = async function () { + await exec(`${MAKE_VOICE_EXE} /u`, await getExecOptions()); +}; + +/** + * @returns {Promise} + */ +async function getExecOptions() { + return { + cwd: resolve(__dirname, '../../Release'), + }; +} diff --git a/package.json b/package.json index 1622767..553717a 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,6 @@ "at-driver": "./bin/at-driver" }, "scripts": { - "postinstall": "Release\\MakeVoice.exe", - "postuninstall": "Release\\MakeVoice.exe /u", "prettier": "prettier --write lib test", "test": "prettier --check lib test && npm run test-types && npm run test-unit", "test-unit": "mocha --ui tdd test/**/*.js", From caee3312f485accf88a5e45ee9f5a794554d3419 Mon Sep 17 00:00:00 2001 From: Mike Pennisi Date: Mon, 1 Apr 2024 16:32:47 -0400 Subject: [PATCH 2/4] Define commands explicitly --- lib/cli.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/cli.js b/lib/cli.js index a845141..f3ca6c5 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -7,6 +7,9 @@ const { hideBin } = require('yargs/helpers'); const createCommandServer = require('./create-command-server'); const createVoiceServer = require('./create-voice-server'); +const installCommand = require('./commands/install'); +const uninstallCommand = require('./commands/uninstall'); + const WINDOWS_NAMED_PIPE = '\\\\?\\pipe\\my_pipe'; const MACOS_SYSTEM_DIR = '/usr/local/var/at_driver_generic'; const MACOS_SOCKET_UNIX_PATH = '/usr/local/var/at_driver_generic/driver.socket'; @@ -23,7 +26,8 @@ const log = (...args) => console.error(new Date().toISOString(), ...args); */ module.exports = async process => { await yargs(hideBin(process.argv)) - .commandDir('./commands') + .command(installCommand) + .command(uninstallCommand) .command({ command: 'serve [port]', // Alias the command as the default command. From ca9ce2294d58448f5f8e17b7b0c1dc31630b3d08 Mon Sep 17 00:00:00 2001 From: Mike Pennisi Date: Mon, 1 Apr 2024 16:51:18 -0400 Subject: [PATCH 3/4] Increase CLI strictness --- README.md | 2 +- lib/cli.js | 6 +++--- test/test.js | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fc4ea9c..9b15738 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ which is required by the automation voice. 3. Start the server by executing the following command in a terminal: - at-driver + at-driver serve The process will write a message to the standard error stream when the WebSocket server is listening for connections. The `--help` flag will cause diff --git a/lib/cli.js b/lib/cli.js index 52df3c7..09e136c 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -29,9 +29,7 @@ module.exports = async process => { .command(installCommand) .command(uninstallCommand) .command({ - command: 'serve [port]', - // Alias the command as the default command. - aliases: ['$0'], + command: 'serve', describe: 'Run at-driver server', builder(yargs) { return yargs.option('port', { @@ -54,6 +52,8 @@ module.exports = async process => { }, handler: serve, }) + .demandCommand(1, 1) + .strict() .help() .parse(); }; diff --git a/test/test.js b/test/test.js index eac87c7..2b776b2 100644 --- a/test/test.js +++ b/test/test.js @@ -56,37 +56,37 @@ suite('at-driver', () => { }); test('WebSocket server on default port', async () => { - const { whenClosed } = await run([]); + const { whenClosed } = await run(['serve']); return Promise.race([whenClosed, connect(4382)]); }); test('WebSocket server on custom port', async () => { - const { whenClosed } = await run(['--port', '6543']); + const { whenClosed } = await run(['serve', '--port', '6543']); return Promise.race([whenClosed, connect(6543)]); }); test('rejects invalid port values: unspecified', async () => { - const { whenClosed } = await run(['--port']); + const { whenClosed } = await run(['serve', '--port']); return invert(whenClosed); }); test('rejects invalid port values: non-numeric', async () => { - const { whenClosed } = await run(['--port', 'seven']); + const { whenClosed } = await run(['serve', '--port', 'seven']); return invert(whenClosed); }); test('rejects invalid port values: negative', async () => { - const { whenClosed } = await run(['--port', '-8000']); + const { whenClosed } = await run(['serve', '--port', '-8000']); return invert(whenClosed); }); test('rejects invalid port values: non-integer', async () => { - const { whenClosed } = await run(['--port', '2004.3']); + const { whenClosed } = await run(['serve', '--port', '2004.3']); return invert(whenClosed); }); test('rejects invalid port values: non-decimal', async () => { - const { whenClosed } = await run(['--port', '0x1000']); + const { whenClosed } = await run(['serve', '--port', '0x1000']); return invert(whenClosed); }); @@ -106,7 +106,7 @@ suite('at-driver', () => { }; setup(async () => { - ({ whenClosed } = await run([])); + ({ whenClosed } = await run(['serve'])); websocket = await Promise.race([whenClosed, connect(4382)]); }); From 74340229663f6aae52c01bbda808f2aa6a4a2336 Mon Sep 17 00:00:00 2001 From: Mike Pennisi Date: Mon, 1 Apr 2024 18:44:16 -0400 Subject: [PATCH 4/4] Normalize command structure --- lib/cli.js | 88 +------------------------------------------ lib/commands/serve.js | 85 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 86 deletions(-) create mode 100644 lib/commands/serve.js diff --git a/lib/cli.js b/lib/cli.js index 09e136c..5fc1fda 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -1,26 +1,12 @@ 'use strict'; -const fs = require('fs/promises'); - const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); -const createCommandServer = require('./create-command-server'); -const createVoiceServer = require('./create-voice-server'); const installCommand = require('./commands/install'); +const serveCommand = require('./commands/serve'); const uninstallCommand = require('./commands/uninstall'); -const WINDOWS_NAMED_PIPE = '\\\\?\\pipe\\my_pipe'; -const MACOS_SYSTEM_DIR = '/usr/local/var/at_driver_generic'; -const MACOS_SOCKET_UNIX_PATH = '/usr/local/var/at_driver_generic/driver.socket'; -const DEFAULT_PORT = 4382; - -/** - * Print logging information to the process's standard error stream, annotated - * with a timestamp describing the moment that the message was emitted. - */ -const log = (...args) => console.error(new Date().toISOString(), ...args); - /** * @param {import('process')} process */ @@ -28,79 +14,9 @@ module.exports = async process => { await yargs(hideBin(process.argv)) .command(installCommand) .command(uninstallCommand) - .command({ - command: 'serve', - describe: 'Run at-driver server', - builder(yargs) { - return yargs.option('port', { - coerce(string) { - if (!/^(0|[1-9][0-9]*)$/.test(string)) { - throw new TypeError( - `"port" option: expected a non-negative integer value but received "${string}"`, - ); - } - return Number(string); - }, - default: DEFAULT_PORT, - describe: 'TCP port on which to listen for WebSocket connections', - // Do not use the `number` type provided by `yargs` because it tolerates - // JavaScript numeric literal forms which are likely typos in this - // context (e.g. `0xf` or `1e-0`). - type: 'string', - requiresArg: true, - }); - }, - handler: serve, - }) + .command(serveCommand) .demandCommand(1, 1) .strict() .help() .parse(); }; - -/** - * @param {import('yargs').Arguments<{port: number}>} argv - */ -async function serve(argv) { - const socketPath = await prepareSocketPath(); - - const [commandServer, voiceServer] = await Promise.all([ - createCommandServer(argv.port), - createVoiceServer(socketPath), - ]); - - log(`listening on port ${argv.port}`); - - commandServer.on('error', error => { - log(`error: ${error}`); - }); - - voiceServer.on('message', message => { - log(`voice server received message ${JSON.stringify(message)}`); - if (message.name == 'speech') { - commandServer.broadcast({ - method: 'interaction.capturedOutput', - params: { data: message.data }, - }); - } - }); - - voiceServer.on('error', error => { - log(`error: ${error}`); - }); -} - -const prepareSocketPath = async () => { - if (process.platform === 'win32') { - return WINDOWS_NAMED_PIPE; - } else if (process.platform === 'darwin') { - await fs.mkdir(MACOS_SYSTEM_DIR, { recursive: true }); - await fs.unlink(MACOS_SOCKET_UNIX_PATH).catch(error => { - if (!error || error.code !== 'ENOENT') { - throw error; - } - }); - return MACOS_SOCKET_UNIX_PATH; - } - throw new Error(`unsupported host platform '${process.platform}'`); -}; diff --git a/lib/commands/serve.js b/lib/commands/serve.js new file mode 100644 index 0000000..0904654 --- /dev/null +++ b/lib/commands/serve.js @@ -0,0 +1,85 @@ +'use strict'; + +const fs = require('fs/promises'); + +const createCommandServer = require('../create-command-server'); +const createVoiceServer = require('../create-voice-server'); + +const WINDOWS_NAMED_PIPE = '\\\\?\\pipe\\my_pipe'; +const MACOS_SYSTEM_DIR = '/usr/local/var/at_driver_generic'; +const MACOS_SOCKET_UNIX_PATH = '/usr/local/var/at_driver_generic/driver.socket'; +const DEFAULT_PORT = 4382; + + +/** + * Print logging information to the process's standard error stream, annotated + * with a timestamp describing the moment that the message was emitted. + */ +const log = (...args) => console.error(new Date().toISOString(), ...args); + +const prepareSocketPath = async () => { + if (process.platform === 'win32') { + return WINDOWS_NAMED_PIPE; + } else if (process.platform === 'darwin') { + await fs.mkdir(MACOS_SYSTEM_DIR, { recursive: true }); + await fs.unlink(MACOS_SOCKET_UNIX_PATH).catch(error => { + if (!error || error.code !== 'ENOENT') { + throw error; + } + }); + return MACOS_SOCKET_UNIX_PATH; + } + throw new Error(`unsupported host platform '${process.platform}'`); +}; + +module.exports = /** @type {import('yargs').CommandModule} */ ({ + command: 'serve', + describe: 'Run at-driver server', + builder(yargs) { + return yargs.option('port', { + coerce(string) { + if (!/^(0|[1-9][0-9]*)$/.test(string)) { + throw new TypeError( + `"port" option: expected a non-negative integer value but received "${string}"`, + ); + } + return Number(string); + }, + default: DEFAULT_PORT, + describe: 'TCP port on which to listen for WebSocket connections', + // Do not use the `number` type provided by `yargs` because it tolerates + // JavaScript numeric literal forms which are likely typos in this + // context (e.g. `0xf` or `1e-0`). + type: 'string', + requiresArg: true, + }); + }, + async handler(argv) { + const socketPath = await prepareSocketPath(); + + const [commandServer, voiceServer] = await Promise.all([ + createCommandServer(argv.port), + createVoiceServer(socketPath), + ]); + + log(`listening on port ${argv.port}`); + + commandServer.on('error', error => { + log(`error: ${error}`); + }); + + voiceServer.on('message', message => { + log(`voice server received message ${JSON.stringify(message)}`); + if (message.name == 'speech') { + commandServer.broadcast({ + method: 'interaction.capturedOutput', + params: { data: message.data }, + }); + } + }); + + voiceServer.on('error', error => { + log(`error: ${error}`); + }); + }, +});