diff --git a/lib/commands/install.js b/lib/commands/install.js index aaec388..4ba182d 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -5,11 +5,17 @@ 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() { + builder(yargs) { + return yargs.option('unattended', { + desc: 'Fail if installation requires human intervention', + boolean: true, + }); + }, + async handler({ unattended }) { const installDelegate = loadOsModule('install', { darwin: () => require('../install/macos'), win32: () => require('../install/win32'), }); - await installDelegate.install(); + await installDelegate.install({ unattended }); }, }); diff --git a/lib/helpers/macos/KeyCode.js b/lib/helpers/macos/KeyCode.js index ab30f14..e4ce91b 100644 --- a/lib/helpers/macos/KeyCode.js +++ b/lib/helpers/macos/KeyCode.js @@ -70,6 +70,7 @@ exports.KeyCode = { numpadAdd: 69, numpadClear: 71, numpadDivide: 75, + enter: 76, numpadSubtract: 78, numpadEqual: 81, numpad0: 82, diff --git a/lib/install/macos.js b/lib/install/macos.js index 374b9ab..13e9752 100644 --- a/lib/install/macos.js +++ b/lib/install/macos.js @@ -1,9 +1,214 @@ 'use strict'; -exports.install = async function () { - throw new Error('macos install not implemented'); +const { exec: _exec } = require('child_process'); +const { resolve } = require('path'); +const { promisify } = require('util'); + +const debug = require('debug')('install'); + +const { 'interaction.pressKeys': pressKeys } = require('../modules/macos/interaction'); + +const LSREGISTER_EXECUTABLE_PATH = + '/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister'; +const APPLICATION_NAME = 'ATDriverGenericMacOS.app'; +const EXTENSION_IDENTIFIER = 'com.bocoup.ATDriverGenericMacOS.ATDriverGenericMacOSExtension'; +const VOICE_IDENTIFIER = + 'com.bocoup.ATDriverGenericMacOS.ATDriverGenericMacOSExtension.ATDriverGenericMacOSExtension'; +const SYSTEM_VOICE_IDENTIFIER = 'com.apple.Fred'; +/** + * This string comprises three tokens (the "type", "subtype", and + * "manufacturer" of the Audio Unit) which must be kept in-sync with other + * references in this project: + * + * - src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS/Model/AudioUnitHostModel.swift + * - src/macos/ATDriverGenericMacOS/ATDriverGenericMacOSExtension/Info.plist + */ +const PLUGIN_TRIPLET_IDENTIFIER = 'ausp atdg BOCU'; + +const exec = promisify(_exec); +const enableKeyAutomationPrompt = `This tool can only be installed on systems which allow automated key pressing. +Please allow the Terminal application to control your computer (the setting is +controlled in System Settings > Privacy & Security > Accessibility).`; + +/** @typedef {import('child_process').ExecOptions} ExecOptions */ + +/** + * Prompt the user to press any key. Resolves when the user presses a key. + * + * @returns {Promise} + */ +const promptForManualKeyPress = async () => { + process.stdout.write('Press any key to continue... '); + const wasRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + const byteArray = await new Promise(resolve => { + process.stdin.once('data', data => resolve(Array.from(data))); + }); + + process.stdin.pause(); + process.stdin.setRawMode(wasRaw); + process.stdout.write('\n'); + + // Honor "Control + C" motion by exiting. + if (byteArray[0] === 3) { + process.exit(1); + } +}; + +/** + * @param {object} options + * @param {boolean} options.unattended - Whether installation should fail if + * human intervention is required + * + * @returns {Promise} + */ +exports.install = async function ({ unattended }) { + const options = await getExecOptions(); + + if (!(await canPressKeys())) { + if (unattended) { + throw new Error('The system cannot automate key pressing.'); + } else { + console.error(enableKeyAutomationPrompt); + + await promptForManualKeyPress(); + + if (!(await canPressKeys())) { + throw new Error('The system cannot automate key pressing.'); + } + } + } + + if (await isInstalled()) { + throw new Error('Already installed'); + } + + await removeQuarantine(options); + await registerExtensions(options); + await enableExtension(); + await setSystemVoice(VOICE_IDENTIFIER); }; +/** + * @returns {Promise} + */ exports.uninstall = async function () { - throw new Error('macos uninstall not implemented'); + const options = await getExecOptions(); + + if (!(await isInstalled())) { + throw new Error('Not installed'); + } + + await setSystemVoice(SYSTEM_VOICE_IDENTIFIER); + await unregisterExtensions(options); }; + +/** + * Experimentally determine whether the current system supports automated key + * pressing by attempting to press an arbitrary key. + * + * @returns {Promise} + */ +const canPressKeys = async () => { + try { + await pressKeys(null, { keys: ['shift'] }); + } catch ({}) { + return false; + } + return true; +}; + +const isInstalled = async function () { + let stdout; + + try { + ({ stdout } = await exec(`auval -v ${PLUGIN_TRIPLET_IDENTIFIER}`)); + } catch (error) { + if (error.stdout && error.stdout.includes("didn't find the component")) { + return false; + } + throw error; + } + + return /ATDriverGenericMacOSExtension/.test(stdout); +}; + +/** + * @returns {Promise} + */ +const getExecOptions = async function () { + return { + cwd: resolve(__dirname, '../../Release/macos'), + }; +}; + +/** + * Remove the "quarantine" attribute which macOS uses to prevent accidental + * execution of code from unverified sources. + * + * https://support.apple.com/en-us/101987 + * + * @param {ExecOptions} options + * @returns {Promise} Whether a change took place + */ +async function removeQuarantine(options) { + debug('Removing macOS quarantine'); + await exec(`xattr -r -d com.apple.quarantine ${APPLICATION_NAME}`, options); + return true; +} + +/** + * @param {ExecOptions} options + * @returns {Promise} + */ +async function registerExtensions(options) { + debug('Registering trusted macOS extension'); + await exec(`${LSREGISTER_EXECUTABLE_PATH} -f -R -trusted ${APPLICATION_NAME}`, options); +} + +/** + * @param {ExecOptions} options + * @returns {Promise} + */ +async function unregisterExtensions(options) { + debug('Unregistering trusted macOS extension'); + await exec(`${LSREGISTER_EXECUTABLE_PATH} -f -R -trusted -u ${APPLICATION_NAME}`, options); +} + +async function enableExtension() { + debug('Enabling macOS extension'); + await exec(`pluginkit -e use -i ${EXTENSION_IDENTIFIER}`); +} + +/** + * @param {string} newValue the identifier for the voice to set + * @returns {Promise} + */ +async function setSystemVoice(newValue) { + debug(`Setting macOS system voice to "${newValue}"`); + let stdout; + + try { + ({ stdout } = await exec( + 'defaults read com.apple.Accessibility SpeechVoiceIdentifierForLanguage', + )); + } catch (error) { + if (!error || !error.stderr.includes('does not exist')) { + throw error; + } + } + + const currentValue = stdout ? stdout.replace(/[\s]/g, '').match(/2={en="([^"]+)";};/) : null; + + debug(`Current value: ${currentValue ? JSON.stringify(currentValue[1]) : '(unset)'}`); + + if (currentValue && currentValue[1] === newValue) { + debug('Already set.'); + return; + } + + await exec( + `defaults write com.apple.Accessibility SpeechVoiceIdentifierForLanguage '{2 = {en = "${newValue}";};}'`, + ); +} diff --git a/lib/modules/macos/session.js b/lib/modules/macos/session.js index e761706..e94419a 100644 --- a/lib/modules/macos/session.js +++ b/lib/modules/macos/session.js @@ -2,18 +2,38 @@ 'use strict'; +const child_process = require('child_process'); + const { v4: uuid } = require('uuid'); +/** + * @returns {Promise} + */ +const getMacOSVersion = async () => { + return new Promise((resolve, reject) => { + child_process.exec('sw_vers -productVersion', (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr)); + return; + } + resolve(stdout.trim()); + }); + }); +}; + const newSession = /** @type {ATDriverModules.SessionNewSession} */ ( - (websocket, params) => { + async (websocket, params) => { // TODO: match requested capabilities // const { capabilities } = params; websocket.sessionId = uuid(); + return { sessionId: websocket.sessionId, capabilities: { atName: 'Voiceover', - atVersion: 'TODO', + // The ARIA-AT Community Group considers the MacOS version identifier + // to be an accurate identifier for the VoiceOver screen reader. + atVersion: await getMacOSVersion(), platformName: 'macos', }, }; diff --git a/package-lock.json b/package-lock.json index 54568bd..8aa436f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,9 @@ "": { "name": "@bocoup/windows-sapi-tts-engine-for-automation", "version": "0.0.4", - "hasInstallScript": true, "license": "MIT", "dependencies": { + "debug": "^4.3.4", "robotjs": "^0.6.0", "uuid": "^9.0.1", "ws": "^8.2.3", @@ -362,10 +362,9 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", - "dev": true, + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dependencies": { "ms": "2.1.2" }, @@ -381,8 +380,7 @@ "node_modules/debug/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/decamelize": { "version": "4.0.0", @@ -904,6 +902,29 @@ "url": "https://opencollective.com/mochajs" } }, + "node_modules/mocha/node_modules/debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, "node_modules/mocha/node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index 46264cd..17f4b4d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "lib", "Release/AutomationTtsEngine.dll", "Release/MakeVoice.exe", - "Release/Vocalizer.exe" + "Release/Vocalizer.exe", + "Release/macos" ], "author": "", "license": "MIT", @@ -31,6 +32,7 @@ "typescript": "^5.3.3" }, "dependencies": { + "debug": "^4.3.4", "robotjs": "^0.6.0", "uuid": "^9.0.1", "ws": "^8.2.3", diff --git a/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS.xcodeproj/xcshareddata/xcschemes/ATDriverGenericMacOSExtension.xcscheme b/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS.xcodeproj/xcshareddata/xcschemes/ATDriverGenericMacOSExtension.xcscheme index 76c7396..12609e4 100644 --- a/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS.xcodeproj/xcshareddata/xcschemes/ATDriverGenericMacOSExtension.xcscheme +++ b/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS.xcodeproj/xcshareddata/xcschemes/ATDriverGenericMacOSExtension.xcscheme @@ -95,7 +95,6 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" - askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS/Model/AudioUnitHostModel.swift b/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS/Model/AudioUnitHostModel.swift index 0f66e7e..b9f1a88 100644 --- a/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS/Model/AudioUnitHostModel.swift +++ b/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOS/Model/AudioUnitHostModel.swift @@ -31,6 +31,11 @@ class AudioUnitHostModel: ObservableObject { let auValString: String + /// These values must be kept in-sync with other references in this + /// project: + /// + /// - lib/install/macos.js + /// - src/macos/ATDriverGenericMacOS/ATDriverGenericMacOSExtension/Info.plist init(type: String = "ausp", subType: String = "atdg", manufacturer: String = "BOCU") { self.type = type self.subType = subType diff --git a/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOSExtension/Info.plist b/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOSExtension/Info.plist index b5434e3..b07e34b 100644 --- a/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOSExtension/Info.plist +++ b/src/macos/ATDriverGenericMacOS/ATDriverGenericMacOSExtension/Info.plist @@ -11,18 +11,39 @@ description ATDriverGenericMacOSExtension + manufacturer BOCU name Bocoup LLC: ATDriverGenericMacOSExtension sandboxSafe + subtype atdg tags Speech Synthesizer + type ausp version