diff --git a/README.md b/README.md index 4ed6c9f..9b15738 100644 --- a/README.md +++ b/README.md @@ -32,20 +32,24 @@ 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 + 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 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 bc30571..5fc1fda 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -1,83 +1,22 @@ '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 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; +const installCommand = require('./commands/install'); +const serveCommand = require('./commands/serve'); +const uninstallCommand = require('./commands/uninstall'); /** - * Print logging information to the process's standard error stream, annotated - * with a timestamp describing the moment that the message was emitted. + * @param {import('process')} process */ -const log = (...args) => console.error(new Date().toISOString(), ...args); - 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); - }, - 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, - }) + await yargs(hideBin(process.argv)) + .command(installCommand) + .command(uninstallCommand) + .command(serveCommand) + .demandCommand(1, 1) + .strict() + .help() .parse(); - - 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/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/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}`); + }); + }, +}); 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", 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)]); });