@heisian/cocoadialog
Version:
Node.js wrapper for cocoaDialog
611 lines (543 loc) • 14.6 kB
JavaScript
const fs = require('fs');
const path = require('path');
const spawn = require('child_process').spawn;
const Promise = require('any-promise');
const CocoaDialogAbort = require('./CocoaDialogAbort');
const CocoaDialogError = require('./CocoaDialogError');
const CocoaDialogTimeout = require('./CocoaDialogTimeout');
const ControlResult = require('./ControlResult');
const emitter = require('./EventEmitter');
// Helper function to determine if control being constructed is a valid test
// control which is used to test the base Control class.
const isTestControl = name => (typeof global.it === 'function' && (name === 'Control' || name === 'FileControl' || name === 'ThreeButtonControl'));
const controls = [
'checkbox', 'dropdown', 'filesave', 'fileselect', 'inputbox', 'msgbox',
'notify', 'ok-msgbox', 'progressbar', 'radio', 'secure-inputbox',
'secure-standard-inputbox', 'slider', 'standard-dropdown',
'standard-inputbox', 'textbox', 'yesno-msgbox'
];
/**
* @class Control
*
* The base class for all controls.
*/
class Control {
/**
* Constructs a new instance.
*
* @param {String} name
* The name of the control.
* @param {...Object} [options]
* One or more option objects to initialize control with.
*
* @constructor
*/
constructor(name, ...options) {
if (!isTestControl(name) && (!name || controls.indexOf(name) === -1)) {
throw new CocoaDialogError('The provided argument is not valid CocoaDialog control:' + name);
}
/**
* @type {Boolean}
*/
this.aborted = false;
/**
* @type {String}
*/
this.bin = Control.getBin();
/**
* @type {String}
*/
this.name = name;
/**
* @type {ChildProcess}
*/
this.childProcess = null;
/**
* @type {Object}
*/
this.options = {};
// Set the initial options.
options.map(this.setOption.bind(this));
}
/**
* A list of all available options.
*
* @return {Object}
*/
availableOptions() {
return {
// Global.
debug: false,
timeout: -1,
timeoutFormat: '',
stringOutput: false,
noNewline: false,
// Panel.
close: false,
height: -1,
minimize: false,
noFloat: false,
posX: '',
posY: '',
resize: false,
title: '',
width: -1,
// Icon.
icon: '',
iconBundle: '',
iconFile: '',
iconHeight: -1,
iconSize: -1,
iconType: '',
iconWidth: -1
};
}
/**
* Close the control dialog by terminating the child process.
*
* @param {Boolean} [abort=false]
* Flag indicating whether or not the process was aborted.
*/
close(abort = false) {
if (abort) {
this.aborted = true;
}
if (this.childProcess) {
this.childProcess.kill();
}
return this;
}
/**
* Enables control debugging.
*
* @param {Boolean} [enabled=true]
* Flag determining whether this option is enabled.
*
* @return {Control}
*/
debug(enabled = true) {
return this.setOption('debug', enabled);
}
/**
* Makes the control float above all windows.
*
* @param {Boolean} [enabled=true]
* Flag determining whether this option is enabled.
*
* @return {Control}
*/
float(enabled = true) {
return this.setOption('noFloat', !enabled);
}
/**
* Retrieves the arguments to use for instantiating the cococaDialog binary.
*
* @return {Array}
* An array of arguments.
*/
getArguments() {
let args = [this.name];
let multiArgs = {};
let availableOptions = this.availableOptions();
// Add arguments based on the order of available options.
for (let name in availableOptions) {
// Skip any options that aren't allowed.
if (!availableOptions.hasOwnProperty(name) || !this.options.hasOwnProperty(name)) {
continue;
}
let value = this.options[name];
// Convert camelCase properties into dashed CLI argument names.
name = name.replace(/[A-Z][a-z0-9-]+/g, (match, offset) => (offset ? '-' : '') + match.toLowerCase());
// Handle multiple values.
if (Array.isArray(value)) {
let values = [];
// Multi-value options can only be strings or numbers.
for (let i = 0, l = value.length; i < l; i++) {
if (typeof value[i] !== 'string' && typeof value[i] !== 'number') {
continue;
}
values.push(value);
}
multiArgs[name] = value;
continue;
}
// Ignore values that are not scalar.
else if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
continue;
}
// Boolean based options get set without values.
if (typeof value === 'boolean') {
if (value === true) {
args.push(`--${name}`);
}
}
else {
args.push(`--${name}`, value);
}
}
// Add arguments that multiple values at the end (helps ensure consistency).
for (let name in multiArgs) {
if (!multiArgs.hasOwnProperty(name)) {
continue;
}
args.push(`--${name}`, ...multiArgs[name]);
}
return args;
}
/**
* Creates a new result object for the control.
*
* @return {ControlResult}
*/
getResult() {
return new ControlResult(this);
}
/**
* Iterates over an array and ensures each Promise has finished.
*
* @param {Array} array
* The array.
* @param {Function} iterator
* The iterator function.
* @param {*} [thisArg]
* The "this" context.
*
* @return {Promise}
*/
mapSeries(array, iterator, thisArg) {
let current = Promise.resolve();
let results = new Array(array.length);
for (let i = 0, l = array.length; i < l; ++i) {
if (this.aborted) {
break;
}
current = results[i] = current.then(function (i) {
return iterator.call(thisArg, array[i], i, array);
}.bind(undefined, i));
}
return Promise.all(results);
}
off() {
emitter.removeListener.apply(emitter, arguments);
return this;
}
/**
* @inheritDoc
*
* @return {Control}
*/
on() {
emitter.on.apply(emitter, arguments);
return this;
}
/**
* @return {Control}
*/
openSync() {
// Ensure this option is always enabled.
this.options.noNewline = true;
let args = this.getArguments();
// Show the command that was executed if debugging.
if (this.options.debug) {
console.log(this.bin + ' ' + args.join(' '));
}
// Create a new result object for the control.
let result = this.getResult();
// Create the child process.
this.childProcess = spawn(this.bin, args);
this.childProcess.stderr.on('data', buffer => {
result.currentLine = buffer.toString();
emitter.emit('stderr', result);
result.raw.stderr += result.currentLine;
});
this.childProcess.stdout.on('data', buffer => {
result.currentLine = buffer.toString();
emitter.emit('stdout', result);
result.raw.stdout += result.currentLine;
});
this.childProcess.on('error', err => {
result.error = err;
emitter.emit('error', result);
});
// Note: cocoaDialog incorrectly and arbitrarily sets the exit code to
// various values based on the user input rather than a true CLI/command
// exit status; it cannot be relied upon in any fashion.
this.childProcess.on('close', () => {
result.currentLine = null;
result.process();
if (result.hasTimedOut()) {
emitter.emit('timeout', result);
}
else if (result.hasAborted()) {
emitter.emit('abort', result);
}
if (result.hasError()) {
emitter.emit('error', result);
}
else {
emitter.emit('result', result);
}
emitter.emit('close', result);
});
return this;
}
/**
* @return {Promise}
*/
open() {
return new Promise((resolve, reject) => {
this.on('result', resolve).on('error', reject).openSync();
});
}
/**
* Sets the control height.
*
* @param {Number} height
* The height to set.
*
* @return {Control}
*/
setHeight(height) {
return this.setOption('height', height);
}
/**
* Sets an icon by name.
*
* @param {String} name
* The name of the icon to set.
* @param {Number|{width: Number, height: Number}} [size]
* The size of the icon. This can be an arbitrary number or an object
* containing specific width and height properties.
*
* @return {Control}
*/
setIcon(name, size) {
this.setOption('icon', name);
if (size) {
this.setIconSize(size);
}
return this;
}
/**
* Sets the icon from a bundle.
*
* @param {String} bundle
* The application bundle name to use.
* @param {Number|{width: Number, height: Number}} [size]
* The size of the icon. This can be an arbitrary number or an object
* containing specific width and height properties.
*
* @return {Control}
*/
setIconFromBundle(bundle, size) {
this.setOption('iconBundle', bundle);
if (size) {
this.setIconSize(size);
}
return this;
}
/**
* Sets the icon from a file.
*
* @param {String} file
* The icon file to use.
* @param {Number|{width: Number, height: Number}} [size]
* The size of the icon. This can be an arbitrary number or an object
* containing specific width and height properties.
*
* @return {Control}
*/
setIconFromFile(file, size) {
this.setOption('iconFile', file);
if (size) {
this.setIconSize(size);
}
return this;
}
/**
* Sets the icon size.
*
* @param {Number|{width: Number, height: Number}} size
* The size of the icon. This can be an arbitrary number or an object
* containing specific width and height properties.
*
* @return {Control}
*/
setIconSize(size) {
if (typeof size !== 'object') {
size = parseInt(size);
size = {width: size, height: size};
}
if (size.height !== undefined && size.width !== undefined) {
this.setOption('iconHeight', size.height);
this.setOption('iconWidth', size.width);
}
return this;
}
/**
* Sets the icon type.
*
* @param {String} type
* The file type of the icon being loaded. By default, cocoaDialog sets
* this to "icns", the macOS icon file type.
*
* @return {Control}
*/
setIconType(type) {
return this.setOption('iconType', type);
}
/**
* Sets an option for the control.
*
* @param {String|Object} name
* The name of the option to set or an object containing key/value pairs
* of options to set.
* @param {*} [value]
* The value of the option to set.
*
* @return {Control}
*/
setOption(name, value) {
let availableOptions = this.availableOptions();
let options = {};
if (typeof name === 'object') {
options = name;
}
else if (typeof name === 'string') {
options[name] = value;
}
for (let name in options) {
// Skip unknown options.
if (!availableOptions.hasOwnProperty(name)) {
continue;
}
let value = options[name];
if (value === undefined || value === false) {
value = null;
}
let validateValue = value => {
if (typeof availableOptions[name] === 'string') {
value = '' + value;
}
else if (typeof availableOptions[name] === 'number') {
value = parseInt(value);
}
else if (typeof availableOptions[name] === 'boolean') {
value = !!value;
}
return value;
};
if (value !== null) {
if (Array.isArray(value)) {
this.options[name] = [];
value.forEach(value => {
this.options[name].push(validateValue(value));
})
}
else {
this.options[name] = validateValue(value);
}
}
else {
delete this.options[name];
}
}
return this;
}
/**
* Sets the position of the control.
*
* @param {Number|'left'|'center'|'right'} x
* The horizontal position.
* @param {Number|'top'|'center'|'bottom'} y
* The vertical position.
*
* @return {Control}
*/
setPosition(x = 0, y = 0) {
return this.setOption('posX', x).setOption('posY', y);
}
/**
* Sets the size of the control.
*
* @param {Number} width
* The width.
* @param {Number} height
* The height.
*
* @return {Control}
*/
setSize(width = 0, height = 0) {
return this.setWidth(width).setHeight(height);
}
/**
* Sets the control title.
*
* @param {String} title
* The title to set.
*
* @return {Control}
*/
setTitle(title) {
return this.setOption('title', title);
}
/**
* Sets a timeout for the control.
*
* @param {Number} seconds
* The number of seconds to wait before the control times out.
* @param {String} [format='Time remaining: %r...']
* The format for the timeout label. By default, cocoaDialog sets this
* to "Time remaining: %r..." internally. If overridden, you can specify
* the following placeholders to be replaced with the actual value:
* - %s: seconds
* - %m: minutes
* - %h: hours
* - %d: days
* - %r: relative
*
* @return {Control}
*/
setTimeout(seconds, format) {
this.setOption('timeout', seconds);
if (format) {
this.setOption('timeoutFormat', format);
}
return this;
}
setWidth(width) {
return this.setOption('width', width);
}
/**
* Sets whether control should return string values instead of integers.
*
* @param {Boolean} [enabled=true]
* Flag determining whether this option is enabled.
*
* @return {Control}
*/
stringOutput(enabled = true) {
return this.setOption('stringOutput', enabled);
}
}
Control.getBin = () => {
let bin = null;
let paths = [
process.env.COCOADIALOG_BIN,
path.resolve(__dirname, '..', 'cocoaDialog.app/Contents/MacOS/cocoaDialog'),
path.join(__dirname, 'cocoaDialog.app/Contents/MacOS/cocoaDialog'),
'/Applications/cocoaDialog.app/Contents/MacOS/cocoaDialog'
];
paths.forEach(path => {
if (!bin && path && fs.existsSync(path)) {
bin = path;
}
});
if (!bin) {
throw new CocoaDialogError('Unable to find cocoaDialog binary.');
}
return bin;
};
Control.noop = () => {
};
module.exports = Control;