homebridge
Version:
HomeKit support for the impatient
381 lines • 18.3 kB
JavaScript
import { execSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { delimiter, join, resolve } from 'node:path';
import process from 'node:process';
import { Logger } from './logger.js';
import { Plugin } from './plugin.js';
const log = Logger.internal;
const require = createRequire(import.meta.url);
const paths = require.resolve.paths('');
/**
* Utility which exposes methods to search for installed Homebridge plugins
*/
export class PluginManager {
// name must be prefixed with 'homebridge-' or '@scope/homebridge-'
static PLUGIN_IDENTIFIER_PATTERN = /^((@[\w-]+(\.[\w-]+)*)\/)?(homebridge-[\w-]+)$/;
api;
searchPaths = new Set(); // unique set of search paths we will use to discover installed plugins
strictPluginResolution = false;
activePlugins;
disabledPlugins;
plugins = new Map();
// we have some plugins which simply pass a wrong or misspelled plugin name to the api calls, this translation tries to mitigate this
pluginIdentifierTranslation = new Map();
accessoryToPluginMap = new Map();
platformToPluginMap = new Map();
currentInitializingPlugin; // used to match registering plugins, see handleRegisterAccessory and handleRegisterPlatform
constructor(api, options) {
this.api = api;
if (options) {
if (options.customPluginPath) {
this.searchPaths.add(resolve(process.cwd(), options.customPluginPath));
}
this.strictPluginResolution = options.strictPluginResolution || false;
this.activePlugins = options.activePlugins;
this.disabledPlugins = Array.isArray(options.disabledPlugins) ? options.disabledPlugins : undefined;
}
this.api.on("registerAccessory" /* InternalAPIEvent.REGISTER_ACCESSORY */, this.handleRegisterAccessory.bind(this));
this.api.on("registerPlatform" /* InternalAPIEvent.REGISTER_PLATFORM */, this.handleRegisterPlatform.bind(this));
}
static isQualifiedPluginIdentifier(identifier) {
return PluginManager.PLUGIN_IDENTIFIER_PATTERN.test(identifier);
}
static extractPluginName(name) {
const match = name.match(PluginManager.PLUGIN_IDENTIFIER_PATTERN);
if (!match) {
throw new Error(`Cannot extract plugin name from invalid identifier: '${name}'`);
}
return match[4];
}
static extractPluginScope(name) {
const match = name.match(PluginManager.PLUGIN_IDENTIFIER_PATTERN);
if (!match) {
throw new Error(`Cannot extract plugin scope from invalid identifier: '${name}'`);
}
return match[2];
}
static getAccessoryName(identifier) {
if (!identifier.includes('.')) {
return identifier;
}
return identifier.split('.')[1];
}
static getPlatformName(identifier) {
if (!identifier.includes('.')) {
return identifier;
}
return identifier.split('.')[1];
}
static getPluginIdentifier(identifier) {
return identifier.split('.')[0];
}
async initializeInstalledPlugins() {
log.info('---');
this.loadInstalledPlugins();
for (const [identifier, plugin] of this.plugins) {
try {
await plugin.load();
}
catch (error) {
log.error('====================');
log.error(`ERROR LOADING PLUGIN ${identifier}:`);
log.error(error.stack);
log.error('====================');
this.plugins.delete(identifier);
continue;
}
if (this.disabledPlugins && this.disabledPlugins.includes(plugin.getPluginIdentifier())) {
plugin.disabled = true;
}
if (plugin.disabled) {
log.warn(`Disabled plugin: ${identifier}@${plugin.version}`);
}
else {
log.info(`Loaded plugin: ${identifier}@${plugin.version}`);
}
await this.initializePlugin(plugin, identifier);
log.info('---');
}
this.currentInitializingPlugin = undefined;
}
async initializePlugin(plugin, identifier) {
try {
this.currentInitializingPlugin = plugin;
await plugin.initialize(this.api); // call the plugin's initializer and pass it our API instance
}
catch (error) {
log.error('====================');
log.error(`ERROR INITIALIZING PLUGIN ${identifier}:`);
log.error(error.stack);
log.error('====================');
this.plugins.delete(identifier);
}
}
handleRegisterAccessory(name, constructor, pluginIdentifier) {
if (!this.currentInitializingPlugin) {
throw new Error(`Unexpected accessory registration. Plugin ${pluginIdentifier ? `'${pluginIdentifier}' ` : ''}tried to register outside the initializer function!`);
}
if (pluginIdentifier && pluginIdentifier !== this.currentInitializingPlugin.getPluginIdentifier()) {
log.info(`Plugin '${this.currentInitializingPlugin.getPluginIdentifier()}' tried to register with an incorrect plugin identifier: '${pluginIdentifier}'. Please report this to the developer!`);
this.pluginIdentifierTranslation.set(pluginIdentifier, this.currentInitializingPlugin.getPluginIdentifier());
}
this.currentInitializingPlugin.registerAccessory(name, constructor);
let plugins = this.accessoryToPluginMap.get(name);
if (!plugins) {
plugins = [];
this.accessoryToPluginMap.set(name, plugins);
}
plugins.push(this.currentInitializingPlugin);
}
handleRegisterPlatform(name, constructor, pluginIdentifier) {
if (!this.currentInitializingPlugin) {
throw new Error(`Unexpected platform registration. Plugin ${pluginIdentifier ? `'${pluginIdentifier}' ` : ''}tried to register outside the initializer function!`);
}
if (pluginIdentifier && pluginIdentifier !== this.currentInitializingPlugin.getPluginIdentifier()) {
log.debug(`Plugin '${this.currentInitializingPlugin.getPluginIdentifier()}' tried to register with an incorrect plugin identifier: '${pluginIdentifier}'. Please report this to the developer!`);
this.pluginIdentifierTranslation.set(pluginIdentifier, this.currentInitializingPlugin.getPluginIdentifier());
}
this.currentInitializingPlugin.registerPlatform(name, constructor);
let plugins = this.platformToPluginMap.get(name);
if (!plugins) {
plugins = [];
this.platformToPluginMap.set(name, plugins);
}
plugins.push(this.currentInitializingPlugin);
}
getPluginForAccessory(accessoryIdentifier) {
let plugin;
if (!accessoryIdentifier.includes('.')) { // see if it matches exactly one accessory
let found = this.accessoryToPluginMap.get(accessoryIdentifier);
if (!found) {
throw new Error(`No plugin was found for the accessory "${accessoryIdentifier}" in your config.json. Please make sure the corresponding plugin is installed correctly.`);
}
if (found.length > 1) {
const options = found.map(plugin => `${plugin.getPluginIdentifier()}.${accessoryIdentifier}`).join(', ');
// check if only one of the multiple platforms is not disabled
found = found.filter(plugin => !plugin.disabled);
if (found.length !== 1) {
throw new Error(`The requested accessory '${accessoryIdentifier}' has been registered multiple times. Please be more specific by writing one of: ${options}`);
}
}
plugin = found[0];
accessoryIdentifier = `${plugin.getPluginIdentifier()}.${accessoryIdentifier}`;
}
else {
const pluginIdentifier = PluginManager.getPluginIdentifier(accessoryIdentifier);
if (!this.hasPluginRegistered(pluginIdentifier)) {
throw new Error(`The requested plugin '${pluginIdentifier}' was not registered.`);
}
plugin = this.getPlugin(pluginIdentifier);
}
return plugin;
}
getPluginForPlatform(platformIdentifier) {
let plugin;
if (!platformIdentifier.includes('.')) { // see if it matches exactly one platform
let found = this.platformToPluginMap.get(platformIdentifier);
if (!found) {
throw new Error(`No plugin was found for the platform "${platformIdentifier}" in your config.json. Please make sure the corresponding plugin is installed correctly.`);
}
if (found.length > 1) {
const options = found.map(plugin => `${plugin.getPluginIdentifier()}.${platformIdentifier}`).join(', ');
// check if only one of the multiple platforms is not disabled
found = found.filter(plugin => !plugin.disabled);
if (found.length !== 1) {
throw new Error(`The requested platform '${platformIdentifier}' has been registered multiple times. Please be more specific by writing one of: ${options}`);
}
}
plugin = found[0];
platformIdentifier = `${plugin.getPluginIdentifier()}.${platformIdentifier}`;
}
else {
const pluginIdentifier = PluginManager.getPluginIdentifier(platformIdentifier);
if (!this.hasPluginRegistered(pluginIdentifier)) {
throw new Error(`The requested plugin '${pluginIdentifier}' was not registered.`);
}
plugin = this.getPlugin(pluginIdentifier);
}
return plugin;
}
hasPluginRegistered(pluginIdentifier) {
return this.plugins.has(pluginIdentifier) || this.pluginIdentifierTranslation.has(pluginIdentifier);
}
getPlugin(pluginIdentifier) {
const plugin = this.plugins.get(pluginIdentifier);
if (plugin) {
return plugin;
}
else {
const translation = this.pluginIdentifierTranslation.get(pluginIdentifier);
if (translation) {
return this.plugins.get(translation);
}
}
return undefined;
}
getPluginByActiveDynamicPlatform(platformName) {
const found = (this.platformToPluginMap.get(platformName) || [])
.filter(plugin => !!plugin.getActiveDynamicPlatform(platformName));
if (found.length === 0) {
return undefined;
}
else if (found.length > 1) {
const plugins = found.map(plugin => plugin.getPluginIdentifier()).join(', ');
throw new Error(`'${platformName}' is an ambiguous platform name. It was registered by multiple plugins: ${plugins}`);
}
else {
return found[0];
}
}
/**
* Gets all plugins installed on the local system
*/
loadInstalledPlugins() {
this.loadDefaultPaths();
this.searchPaths.forEach((searchPath) => {
if (!existsSync(searchPath)) { // just because this path is in require.main.paths doesn't mean it necessarily exists!
return;
}
if (existsSync(join(searchPath, 'package.json'))) { // does this path point inside a single plugin and not a directory containing plugins?
try {
this.loadPlugin(searchPath);
}
catch (error) {
log.warn(error.message);
}
}
else { // read through each directory in this node_modules folder
let relativePluginPaths = readdirSync(searchPath) // search for directories only
.filter((relativePath) => {
try {
return statSync(resolve(searchPath, relativePath)).isDirectory();
}
catch (error) {
log.debug(`Ignoring path ${resolve(searchPath, relativePath)} - ${error.message}`);
return false;
}
});
// expand out @scoped plugins
const scopeDirectories = relativePluginPaths.filter(path => path.startsWith('@'));
relativePluginPaths = relativePluginPaths.filter(path => !path.startsWith('@'));
for (const scopeDirectory of scopeDirectories) {
const absolutePath = join(searchPath, scopeDirectory);
readdirSync(absolutePath)
.filter(name => PluginManager.isQualifiedPluginIdentifier(name))
.filter((name) => {
try {
return statSync(resolve(absolutePath, name)).isDirectory();
}
catch (error) {
log.debug(`Ignoring path ${resolve(absolutePath, name)} - ${error.message}`);
return false;
}
})
.forEach(name => relativePluginPaths.push(`${scopeDirectory}/${name}`));
}
relativePluginPaths
.filter((pluginIdentifier) => {
return PluginManager.isQualifiedPluginIdentifier(pluginIdentifier) // needs to be a valid homebridge plugin name
&& (!this.activePlugins || this.activePlugins.includes(pluginIdentifier)); // check if activePlugins is restricted and if so is the plugin is contained
})
.forEach((pluginIdentifier) => {
try {
const absolutePath = resolve(searchPath, pluginIdentifier);
this.loadPlugin(absolutePath);
}
catch (error) {
log.warn(error.message);
}
});
}
});
if (this.plugins.size === 0) {
log.warn('No plugins found.');
}
}
loadPlugin(absolutePath) {
const packageJson = PluginManager.loadPackageJSON(absolutePath);
const identifier = packageJson.name;
const name = PluginManager.extractPluginName(identifier);
const scope = PluginManager.extractPluginScope(identifier); // possibly undefined
const alreadyInstalled = this.plugins.get(identifier); // check if there is already a plugin with the same Identifier
if (alreadyInstalled) {
throw new Error(`Warning: skipping plugin found at '${absolutePath}' since we already loaded the same plugin from '${alreadyInstalled.getPluginPath()}'.`);
}
const plugin = new Plugin(name, absolutePath, packageJson, scope);
this.plugins.set(identifier, plugin);
return plugin;
}
static loadPackageJSON(pluginPath) {
const packageJsonPath = join(pluginPath, 'package.json');
let packageJson;
if (!existsSync(packageJsonPath)) {
throw new Error(`Plugin ${pluginPath} does not contain a package.json.`);
}
try {
packageJson = JSON.parse(readFileSync(packageJsonPath, { encoding: 'utf8' })); // attempt to parse package.json
}
catch (error) {
throw new Error(`Plugin ${pluginPath} contains an invalid package.json. Error: ${error}`);
}
if (!packageJson.name || !PluginManager.isQualifiedPluginIdentifier(packageJson.name)) {
throw new Error(`Plugin ${pluginPath} does not have a package name that begins with 'homebridge-' or '@scope/homebridge-.`);
}
// verify that it's tagged with the correct keyword
if (!packageJson.keywords || !packageJson.keywords.includes('homebridge-plugin')) {
throw new Error(`Plugin ${pluginPath} package.json does not contain the keyword 'homebridge-plugin'.`);
}
return packageJson;
}
loadDefaultPaths() {
if (this.strictPluginResolution) {
// if strict plugin resolution is enabled:
// * only use custom plugin path, if set;
// * otherwise add the current npm global prefix (e.g. /usr/local/lib/node_modules)
if (this.searchPaths.size === 0) {
this.addNpmPrefixToSearchPaths();
}
return;
}
if (paths) {
// add the paths used by require()
paths.forEach(path => this.searchPaths.add(path));
}
// THIS SECTION FROM: https://github.com/yeoman/environment/blob/master/lib/resolver.js
// Adding global npm directories
// We tried using npm to get the global modules path, but it hasn't work out
// because of bugs in the parsable implementation of `ls` command and mostly
// performance issues. So, we go with our best bet for now.
if (process.env.NODE_PATH) {
process.env
.NODE_PATH
.split(delimiter)
.filter(path => !!path) // trim out empty values
.forEach(path => this.searchPaths.add(path));
}
else {
// Default paths for non-windows systems
if (process.platform !== 'win32') {
this.searchPaths.add('/usr/local/lib/node_modules');
this.searchPaths.add('/usr/lib/node_modules');
}
this.addNpmPrefixToSearchPaths();
}
}
addNpmPrefixToSearchPaths() {
if (process.platform === 'win32') {
this.searchPaths.add(join(process.env.APPDATA, 'npm/node_modules'));
}
else {
this.searchPaths.add(execSync('/bin/echo -n "$(npm -g prefix)/lib/node_modules"', {
env: {
npm_config_loglevel: 'silent',
npm_update_notifier: 'false',
...process.env,
},
}).toString('utf8'));
}
}
}
//# sourceMappingURL=pluginManager.js.map