Skip to content

Commit

Permalink
Merge pull request #43 from w3c/automate-darwin-ci-install
Browse files Browse the repository at this point in the history
Automate darwin install
  • Loading branch information
jugglinmike authored May 16, 2024
2 parents f8403be + 48bc032 commit 74b5e29
Show file tree
Hide file tree
Showing 9 changed files with 296 additions and 16 deletions.
10 changes: 8 additions & 2 deletions lib/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
});
1 change: 1 addition & 0 deletions lib/helpers/macos/KeyCode.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ exports.KeyCode = {
numpadAdd: 69,
numpadClear: 71,
numpadDivide: 75,
enter: 76,
numpadSubtract: 78,
numpadEqual: 81,
numpad0: 82,
Expand Down
211 changes: 208 additions & 3 deletions lib/install/macos.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>}
*/
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<ExecOptions>}
*/
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<boolean>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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}";};}'`,
);
}
24 changes: 22 additions & 2 deletions lib/modules/macos/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,38 @@

'use strict';

const child_process = require('child_process');

const { v4: uuid } = require('uuid');

/**
* @returns {Promise<string>}
*/
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',
},
};
Expand Down
35 changes: 28 additions & 7 deletions package-lock.json

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

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"lib",
"Release/AutomationTtsEngine.dll",
"Release/MakeVoice.exe",
"Release/Vocalizer.exe"
"Release/Vocalizer.exe",
"Release/macos"
],
"author": "",
"license": "MIT",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 74b5e29

Please sign in to comment.