homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
1,025 lines • 74.3 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var PluginsService_1;
import { execSync, fork, spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { constants, existsSync } from 'node:fs';
import { access, readdir, readFile, realpath, stat } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { arch, cpus, platform, userInfo } from 'node:os';
import { basename, delimiter, dirname, join, resolve, sep, } from 'node:path';
import process from 'node:process';
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import axios from 'axios';
import { cyan, green, red, yellow } from 'bash-color';
import { createFile, ensureDir, pathExists, pathExistsSync, readJson, remove } from 'fs-extra/esm';
import _ from 'lodash';
import NodeCache from 'node-cache';
import pLimit from 'p-limit';
import { firstValueFrom } from 'rxjs';
import { gt, lt, parse, rcompare, satisfies } from 'semver';
import { ConfigService } from '../../core/config/config.service.js';
import { HomebridgeIpcService } from '../../core/homebridge-ipc/homebridge-ipc.service.js';
import { Logger } from '../../core/logger/logger.service.js';
import { NodePtyService } from '../../core/node-pty/node-pty.service.js';
import { RE_ENCODED_AT, RE_GITHUB_REPO, RE_HYPHEN, RE_HYPHEN_GLOBAL, RE_NON_NUMERIC_DOT, RE_PLUGIN_NAME, RE_URL, RE_URL_WITH_OPTIONAL_PAREN, RE_WHITESPACE, RE_WORD_SEQUENCE, } from '../../core/regex.constants.js';
import { ChildBridgesService } from '../child-bridges/child-bridges.service.js';
const { orderBy, uniq } = _;
const require = createRequire(import.meta.url);
const module = require('node:module');
let PluginsService = class PluginsService {
static { PluginsService_1 = this; }
httpService;
nodePtyService;
logger;
configService;
homebridgeIpcService;
childBridgesService;
_npm;
_paths;
get npm() {
if (!this._npm) {
this._npm = this.getNpmPath();
}
return this._npm;
}
get paths() {
if (!this._paths) {
this._paths = this.getBasePaths();
}
return this._paths;
}
static UI_RESTART_DELAY_MS = 5000;
installedPlugins;
npmPackage;
pluginListUrl = 'https://raw.githubusercontent.com/homebridge/plugins/latest/';
pluginListFile = `${this.pluginListUrl}assets/plugins-v2.min.json`;
pluginListRetryTimeout;
hiddenPlugins = [];
hiddenScopes = [];
unmaintainedPlugins = [];
pluginIcons = {};
pluginAuthors = {};
pluginNames = {};
pluginChangelogs = {};
newScopePlugins = {};
scopedPluginNames = [];
verifiedPlugins = [];
verifiedPlusPlugins = [];
npmPluginCache = new NodeCache({ stdTTL: 300 });
pluginAliasCache = new NodeCache({ stdTTL: 86400 });
installedPluginsCache = new NodeCache({ stdTTL: 60 });
pluginAliasHints = {
'homebridge-broadlink-rm-pro': {
pluginAlias: 'BroadlinkRM',
pluginType: 'platform',
},
};
constructor(httpService, nodePtyService, logger, configService, homebridgeIpcService, childBridgesService) {
this.httpService = httpService;
this.nodePtyService = nodePtyService;
this.logger = logger;
this.configService = configService;
this.homebridgeIpcService = homebridgeIpcService;
this.childBridgesService = childBridgesService;
this.httpService.axiosRef.interceptors.request.use((config) => {
const source = axios.CancelToken.source();
config.cancelToken = source.token;
setTimeout(() => {
source.cancel('Timeout: request took more than 35 seconds');
}, 35000);
return config;
});
this.loadPluginList().catch((err) => {
this.logger.error('Failed to load plugin list during initialization:', err);
});
setInterval(this.loadPluginList.bind(this), 60000 * 60 * 12);
}
fixDisplayName(plugin) {
plugin.displayName = plugin.displayName || (plugin.name.charAt(0) === '@' ? plugin.name.split('/')[1] : plugin.name)
.replace(RE_HYPHEN_GLOBAL, ' ')
.replace(RE_WORD_SEQUENCE, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase());
return plugin;
}
async getInstalledPlugins() {
const cached = this.installedPluginsCache.get('installed-plugins');
if (cached) {
this.installedPlugins = cached;
return cached;
}
const plugins = [];
const modules = await this.getInstalledModules();
const disabledPlugins = await this.getDisabledPlugins();
const homebridgePlugins = modules.filter(module => ((module.name.indexOf('homebridge-') === 0) || this.isScopedPlugin(module.name))
&& pathExistsSync(join(module.installPath, 'package.json')));
const limit = pLimit(cpus().length);
await Promise.all(homebridgePlugins.map(async (pkg) => {
return limit(async () => {
try {
const pkgJson = await readJson(join(pkg.installPath, 'package.json'));
if (pkgJson.keywords && pkgJson.keywords.includes('homebridge-plugin')) {
const plugin = await this.parsePackageJson(pkgJson, pkg.path);
plugin.disabled = disabledPlugins.includes(plugin.name);
const existingPlugin = plugins.find(x => plugin.name === x.name);
if (!existingPlugin) {
plugins.push(plugin);
}
else if (!plugin.globalInstall && existingPlugin.globalInstall === true) {
const index = plugins.indexOf(existingPlugin);
plugins[index] = plugin;
}
}
}
catch (e) {
this.logger.error(`Failed to parse plugin ${pkg.name} as ${e.message}.`);
}
});
}));
this.installedPlugins = plugins.map(plugin => this.fixDisplayName(plugin));
this.installedPluginsCache.set('installed-plugins', this.installedPlugins);
return this.installedPlugins;
}
async getOutOfDatePlugins() {
const plugins = await this.getInstalledPlugins();
return plugins.filter(x => x.updateAvailable);
}
async lookupPlugin(pluginName) {
if (!RE_PLUGIN_NAME.test(pluginName)) {
throw new BadRequestException('Invalid plugin name.');
}
const lookup = await this.searchNpmRegistrySingle(pluginName);
if (!lookup.length) {
throw new NotFoundException();
}
return lookup[0];
}
async getAvailablePluginVersions(pluginName) {
if (!RE_PLUGIN_NAME.test(pluginName) && pluginName !== 'homebridge') {
throw new BadRequestException('Invalid plugin name.');
}
try {
const fromCache = this.npmPluginCache.get(`lookup-${pluginName}`);
const pkg = fromCache || (await firstValueFrom((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(pluginName).replace(RE_ENCODED_AT, '@')}`, {
headers: {
accept: 'application/vnd.npm.install-v1+json',
},
})))).data;
if (!fromCache) {
this.npmPluginCache.set(`lookup-${pluginName}`, pkg, 60);
}
return {
tags: pkg['dist-tags'],
versions: Object.keys(pkg.versions).reduce((acc, key) => {
if (!pkg.versions[key].deprecated) {
acc[key] = {
version: pkg.versions[key].version,
engines: pkg.versions[key].engines || null,
};
}
return acc;
}, {}),
};
}
catch (e) {
throw new NotFoundException();
}
}
extractTerms(query, separator) {
return query
.toLowerCase()
.split(separator)
.map(term => term.trim())
.filter(term => term && term !== 'homebridge' && term !== 'plugin');
}
getPluginKeywords(plugin) {
return Array.isArray(plugin.keywords)
? plugin.keywords.map((k) => k.toLowerCase())
: [];
}
matchesPlugin(plugin, searchTerms) {
const pluginName = plugin.name.toLowerCase();
const pluginKeywords = this.getPluginKeywords(plugin);
const pluginDescription = (plugin.description || '').toLowerCase();
const nameTerms = this.extractTerms(pluginName.substring(pluginName.lastIndexOf('/') + 1), RE_HYPHEN);
const searchTermsSet = new Set(searchTerms);
const keywordsSet = new Set(pluginKeywords);
const nameTermsSet = new Set(nameTerms);
if (nameTerms.every(term => searchTermsSet.has(term))) {
return 'exactName';
}
if (searchTerms.every(term => keywordsSet.has(term)) || searchTerms.every(term => nameTermsSet.has(term))) {
return 'exactKeyword';
}
if (searchTerms.some(term => pluginName.includes(term))
|| searchTerms.some(term => pluginKeywords.some(k => k.includes(term)))
|| searchTerms.some(term => pluginDescription.includes(term))) {
return 'partial';
}
return null;
}
async searchNpmRegistry(query) {
if (!this.installedPlugins) {
await this.getInstalledPlugins();
}
const searchTerms = this.extractTerms(query, RE_WHITESPACE);
const normalizedQuery = searchTerms.length > 0 ? searchTerms.join(' ') : 'homebridge';
if ((normalizedQuery.startsWith('homebridge-') || this.isScopedPlugin(normalizedQuery))
&& !this.isHiddenPlugin(normalizedQuery)) {
if (!this.installedPlugins.some(x => x.name === normalizedQuery)
&& Object.keys(this.newScopePlugins).includes(normalizedQuery)) {
return await this.searchNpmRegistrySingle(`@homebridge-plugins/${normalizedQuery}`);
}
return await this.searchNpmRegistrySingle(normalizedQuery);
}
const q = `${normalizedQuery.substring(0, 15)}+keywords:homebridge-plugin+not:deprecated&size=99`;
let searchResults;
try {
searchResults = (await firstValueFrom(this.httpService.get(`https://registry.npmjs.org/-/v1/search?text=${q}`))).data;
}
catch (e) {
this.logger.error(`Failed to search the npm registry (see https://homebridge.io/w/JJSz6 for help) as ${e.message}.`);
throw new InternalServerErrorException(`Failed to search the npm registry as ${e.message}, see logs.`);
}
const plugins = searchResults.objects
.filter(x => (x.package.name.startsWith('homebridge-') || this.isScopedPlugin(x.package.name))
&& !this.isHiddenPlugin(x.package.name))
.map((pkg) => {
const isInstalled = this.installedPlugins.find(x => x.name === pkg.package.name);
if (isInstalled) {
return {
...isInstalled,
lastUpdated: pkg.package.date,
keywords: pkg.package.keywords || [],
};
}
return {
name: pkg.package.name,
displayName: this.pluginNames[pkg.package.name],
private: false,
publicPackage: true,
installedVersion: null,
latestVersion: pkg.package.version,
lastUpdated: pkg.package.date,
description: (pkg.package.description || pkg.package.name).replace(RE_URL_WITH_OPTIONAL_PAREN, '').trim(),
keywords: pkg.package.keywords || [],
links: pkg.package.links,
author: this.pluginAuthors[pkg.package.name] || (pkg.package.publisher ? pkg.package.publisher.username : null),
verifiedPlugin: this.verifiedPlugins.includes(pkg.package.name),
verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkg.package.name),
icon: this.pluginIcons[pkg.package.name] ? `${this.pluginListUrl}${this.pluginIcons[pkg.package.name]}` : null,
isHbScoped: pkg.package.name.startsWith('@homebridge-plugins/'),
newHbScope: this.newScopePlugins[pkg.package.name],
isUnmaintained: this.unmaintainedPlugins.includes(pkg.package.name),
};
});
const resultNames = new Set(plugins.map(p => p.name));
const scopedLookups = [];
for (const name of this.scopedPluginNames) {
if (!resultNames.has(name) && !this.isHiddenPlugin(name)) {
const unscopedName = name.substring(name.lastIndexOf('/') + 1).toLowerCase();
if (searchTerms.some(term => unscopedName.includes(term))) {
scopedLookups.push(this.searchNpmRegistrySingle(name).catch(() => []));
}
}
}
if (scopedLookups.length > 0) {
const scopedResults = await Promise.all(scopedLookups);
for (const results of scopedResults) {
for (const plugin of results) {
if (!resultNames.has(plugin.name)) {
plugins.push(plugin);
resultNames.add(plugin.name);
}
}
}
}
const matchGroups = {
exactName: [],
exactKeyword: [],
partial: [],
};
for (const plugin of plugins) {
const matchType = this.matchesPlugin(plugin, searchTerms);
if (matchType) {
matchGroups[matchType].push(plugin);
}
}
const orderPlugins = (arr) => orderBy(arr, ['verifiedPlusPlugin', 'verifiedPlugin', 'lastUpdated'], ['desc', 'desc', 'desc']);
const allResults = [
...matchGroups.exactName,
...matchGroups.exactKeyword,
...matchGroups.partial,
];
const scopedResults = allResults.filter(p => p.isHbScoped);
const unscopedResults = allResults.filter(p => !p.isHbScoped);
return [...orderPlugins(scopedResults), ...orderPlugins(unscopedResults)]
.slice(0, 30)
.map(plugin => this.fixDisplayName(plugin));
}
async searchNpmRegistrySingle(query) {
try {
const fromCache = this.npmPluginCache.get(`lookup-${query}`);
const pkg = fromCache || (await firstValueFrom((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(query).replace(RE_ENCODED_AT, '@')}`)))).data;
if (!fromCache) {
this.npmPluginCache.set(`lookup-${query}`, pkg, 60);
}
if (!pkg.keywords || !pkg.keywords.includes('homebridge-plugin')) {
return [];
}
let plugin;
if (!this.installedPlugins) {
await this.getInstalledPlugins();
}
const isInstalled = this.installedPlugins.find(x => x.name === pkg.name);
if (isInstalled) {
plugin = isInstalled;
plugin.lastUpdated = pkg.time.modified;
return [plugin];
}
plugin = {
name: pkg.name,
private: false,
description: (pkg.description)
? pkg.description.replace(RE_URL, '').trim()
: pkg.name,
verifiedPlugin: this.verifiedPlugins.includes(pkg.name),
verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkg.name),
icon: this.pluginIcons[pkg.name],
isHbScoped: pkg.name.startsWith('@homebridge-plugins/'),
newHbScope: this.newScopePlugins[pkg.name],
isUnmaintained: this.unmaintainedPlugins.includes(pkg.name),
};
plugin.displayName = this.pluginNames[pkg.name];
plugin.publicPackage = true;
plugin.latestVersion = pkg['dist-tags'] ? pkg['dist-tags'].latest : undefined;
plugin.lastUpdated = pkg.time.modified;
plugin.updateAvailable = false;
plugin.updateTag = null;
plugin.links = {
npm: `https://www.npmjs.com/package/${plugin.name}`,
homepage: pkg.homepage,
bugs: typeof pkg.bugs === 'object' && pkg.bugs?.url ? pkg.bugs.url : null,
};
plugin.author = this.pluginAuthors[pkg.name]
|| ((pkg.maintainers && pkg.maintainers.length) ? pkg.maintainers[0].name : null);
plugin.verifiedPlugin = this.verifiedPlugins.includes(pkg.name);
plugin.verifiedPlusPlugin = this.verifiedPlusPlugins.includes(pkg.name);
plugin.icon = this.pluginIcons[pkg.name]
? `${this.pluginListUrl}${this.pluginIcons[pkg.name]}`
: null;
plugin.isHbScoped = pkg.name.startsWith('@homebridge-plugins/');
plugin.newHbScope = this.newScopePlugins[pkg.name];
plugin.isUnmaintained = this.unmaintainedPlugins.includes(pkg.name);
return [this.fixDisplayName(plugin)];
}
catch (e) {
if (e.response?.status !== 404) {
this.logger.error(`Failed to search the npm registry (see https://homebridge.io/w/JJSz6 for help) as ${e.message}.`);
}
return [];
}
}
async manageUi(action, pluginAction, client) {
if (action === 'uninstall') {
throw new Error('Cannot uninstall the Homebridge UI.');
}
if (this.configService.dockerOfflineUpdate && pluginAction.version === 'latest') {
await this.updateSelfOffline(client);
return true;
}
if (action === 'install' && pluginAction.version === 'latest') {
pluginAction.version = await this.getNpmModuleLatestVersion(pluginAction.name);
}
const userPlatform = platform();
let installPath = this.configService.customPluginPath
? this.configService.customPluginPath
: this.installedPlugins.find(x => x.name === this.configService.name).installPath;
await this.getInstalledPlugins();
const existingPlugin = this.installedPlugins.find(x => x.name === pluginAction.name);
if (existingPlugin) {
installPath = existingPlugin.installPath;
}
const githubReleaseName = await this.isUiUpdateBundleAvailable(pluginAction);
if (githubReleaseName) {
try {
await this.doUiBundleUpdate(pluginAction, client, githubReleaseName);
return true;
}
catch (e) {
client.emit('stdout', yellow('\r\nBundled update failed. Trying regular update using npm.\r\n\r\n'));
}
}
if (cpus().length === 1 && arch() === 'arm') {
client.emit('stdout', yellow('***************************************************************\r\n'));
client.emit('stdout', yellow(`Please be patient while ${this.configService.name} updates.\r\n`));
client.emit('stdout', yellow('This process may take 5-15 minutes to complete on your device.\r\n'));
client.emit('stdout', yellow('***************************************************************\r\n\r\n'));
}
const installOptions = [];
if (installPath === this.configService.customPluginPath && await pathExists(resolve(installPath, '../package.json'))) {
installOptions.push('--save');
}
installPath = resolve(installPath, '../');
if (!this.configService.customPluginPath || userPlatform === 'win32' || existingPlugin?.globalInstall === true) {
installOptions.push('-g');
}
installOptions.push('--omit=dev');
const npmPluginLabel = `${pluginAction.name}@${pluginAction.version}`;
await this.cleanNpmCache();
await this.runNpmCommand([...this.npm, action, ...installOptions, npmPluginLabel], installPath, client, pluginAction.termCols, pluginAction.termRows);
await this.ensureCustomPluginDirExists();
return true;
}
async managePlugin(action, pluginAction, client) {
pluginAction.version = pluginAction.version || 'latest';
if (pluginAction.name === this.configService.name) {
return await this.manageUi(action, pluginAction, client);
}
if (action === 'install' && pluginAction.version === 'latest') {
pluginAction.version = await this.getNpmModuleLatestVersion(pluginAction.name);
}
await this.getInstalledPlugins();
let installPath = this.configService.customPluginPath
? this.configService.customPluginPath
: this.installedPlugins.find(x => x.name === this.configService.name).installPath;
const existingPlugin = this.installedPlugins.find(x => x.name === pluginAction.name);
if (existingPlugin) {
installPath = existingPlugin.installPath;
}
if (action === 'install' && await this.isPluginBundleAvailable(pluginAction)) {
try {
await this.doPluginBundleUpdate(pluginAction, client);
return true;
}
catch (e) {
client.emit('stdout', yellow('\r\nBundled install / update could not complete. Trying regular install / update using npm.\r\n\r\n'));
}
}
const installOptions = [];
let npmPluginLabel = pluginAction.name;
if (installPath === this.configService.customPluginPath && await pathExists(resolve(installPath, '../package.json'))) {
installOptions.push('--save');
}
installPath = resolve(installPath, '../');
if (!this.configService.customPluginPath || platform() === 'win32' || existingPlugin?.globalInstall === true) {
installOptions.push('-g');
}
if (action === 'install') {
installOptions.push('--omit=dev');
npmPluginLabel = `${pluginAction.name}@${pluginAction.version}`;
}
await this.cleanNpmCache();
await this.runNpmCommand([...this.npm, action, ...installOptions, npmPluginLabel], installPath, client, pluginAction.termCols, pluginAction.termRows);
await this.ensureCustomPluginDirExists();
return true;
}
async getHomebridgePackage() {
if (this.configService.ui.homebridgePackagePath) {
const pkgJsonPath = join(this.configService.ui.homebridgePackagePath, 'package.json');
if (await pathExists(pkgJsonPath)) {
return await this.parsePackageJson(await readJson(pkgJsonPath), this.configService.ui.homebridgePackagePath);
}
else {
this.logger.error(`The Homebridge path ${this.configService.ui.homebridgePackagePath} does not exist.`);
}
}
const modules = await this.getInstalledModules();
const homebridgeInstalls = modules.filter(x => x.name === 'homebridge');
if (homebridgeInstalls.length > 1) {
this.logger.warn('Multiple instances of Homebridge were found, see https://homebridge.io/w/JJSgm for help.');
homebridgeInstalls.forEach((instance) => {
this.logger.warn(instance.installPath);
});
}
if (!homebridgeInstalls.length) {
this.configService.hbServiceUiRestartRequired = true;
this.logger.error('Unable to find Homebridge installation, see https://homebridge.io/w/JJSgZ for help.');
throw new Error('Unable To Find Homebridge Installation.');
}
const homebridgeModule = homebridgeInstalls[0];
const pkgJson = await readJson(join(homebridgeModule.installPath, 'package.json'));
const homebridge = await this.parsePackageJson(pkgJson, homebridgeModule.path);
if (!homebridge.latestVersion) {
return homebridge;
}
const updatePolicy = this.configService.ui.homebridgeUpdatePolicy || 'all';
if (updatePolicy === 'none') {
homebridge.updateAvailable = false;
homebridge.latestVersion = null;
}
else if (updatePolicy === 'major') {
const currentMajor = Number.parseInt(homebridge.installedVersion.split('.')[0], 10);
const versions = await this.getAvailablePluginVersions('homebridge');
const sameMajorVersions = Object.keys(versions.versions)
.filter((version) => {
const versionMajor = Number.parseInt(version.split('.')[0], 10);
return versionMajor === currentMajor;
})
.sort(rcompare);
if (sameMajorVersions.length > 0 && gt(sameMajorVersions[0], homebridge.installedVersion)) {
homebridge.latestVersion = sameMajorVersions[0];
homebridge.updateAvailable = true;
homebridge.updateEngines = versions.versions[sameMajorVersions[0]]?.engines || null;
}
else {
homebridge.updateAvailable = false;
}
}
else if (updatePolicy === 'beta') {
await this.checkForBetaUpdates(homebridge, 'homebridge', true);
}
this.configService.homebridgeVersion = homebridge.installedVersion;
return homebridge;
}
async updateHomebridgePackage(homebridgeUpdateAction, client) {
const homebridge = await this.getHomebridgePackage();
homebridgeUpdateAction.version = homebridgeUpdateAction.version || 'latest';
if (homebridgeUpdateAction.version === 'latest' && homebridge.latestVersion) {
homebridgeUpdateAction.version = homebridge.latestVersion;
}
let installPath = homebridge.installPath;
const installOptions = [];
installOptions.push('--omit=dev');
if (installPath === this.configService.customPluginPath && await pathExists(resolve(installPath, '../package.json'))) {
installOptions.push('--save');
}
installPath = resolve(installPath, '../');
if (homebridge.globalInstall || platform() === 'win32') {
installOptions.push('-g');
}
await this.runNpmCommand([...this.npm, 'install', ...installOptions, `${homebridge.name}@${homebridgeUpdateAction.version}`], installPath, client, homebridgeUpdateAction.termCols, homebridgeUpdateAction.termRows);
return true;
}
async triggerUpdate(name, version) {
if (version !== undefined && typeof version !== 'string') {
throw new BadRequestException('Invalid version parameter.');
}
let targetVersion = version || 'latest';
try {
switch (name) {
case 'homebridge': {
const homebridge = await this.getHomebridgePackage();
if (targetVersion === 'latest' && homebridge.latestVersion) {
targetVersion = homebridge.latestVersion;
}
break;
}
case 'homebridge-config-ui-x': {
const uiPackage = await this.getHomebridgeUiPackage();
if (!uiPackage) {
throw new NotFoundException(`Package ${name} is not installed.`);
}
if (targetVersion === 'latest' && uiPackage.latestVersion) {
targetVersion = uiPackage.latestVersion;
}
break;
}
default: {
if (!RE_PLUGIN_NAME.test(name)) {
throw new BadRequestException('Invalid package name. Must be "homebridge", "homebridge-config-ui-x", or a valid Homebridge plugin name.');
}
const plugins = await this.getInstalledPlugins();
const plugin = plugins.find(p => p.name === name);
if (!plugin) {
throw new NotFoundException(`Plugin ${name} is not installed.`);
}
if (targetVersion === 'latest' && plugin.latestVersion) {
targetVersion = plugin.latestVersion;
}
}
}
}
catch (e) {
if (e instanceof NotFoundException) {
throw e;
}
this.logger.error(`Failed to validate package ${name} for update: ${e.message}`);
throw new BadRequestException(`Failed to validate package ${name} for update.`);
}
setImmediate(async () => {
try {
this.logger.log(`Starting scheduled update for ${name} to version ${targetVersion}`);
const mockClient = new EventEmitter();
mockClient.on('stdout', (data) => {
this.logger.log(`[${name} update] ${data.toString().trim()}`);
});
if (name === 'homebridge') {
await this.updateHomebridgePackage({ version: targetVersion }, mockClient);
this.logger.log(`Successfully updated Homebridge to version ${targetVersion}. Performing quick restart of Homebridge process...`);
this.homebridgeIpcService.restartHomebridge();
}
else if (name === this.configService.name) {
await this.managePlugin('install', { name, version: targetVersion }, mockClient);
this.logger.warn(`homebridge-config-ui-x has been updated, server will restart in ${PluginsService_1.UI_RESTART_DELAY_MS / 1000} seconds...`);
setTimeout(() => {
process.exit(0);
}, PluginsService_1.UI_RESTART_DELAY_MS);
}
else {
await this.managePlugin('install', { name, version: targetVersion }, mockClient);
this.logger.log(`Successfully updated ${name} to version ${targetVersion}.`);
const childBridgeUsernames = await this.getPluginChildBridgeUsernames(name);
if (childBridgeUsernames.length > 0) {
this.logger.log(`${name} is running in ${childBridgeUsernames.length} child bridge(s). Restarting child bridges: ${childBridgeUsernames.join(', ')}`);
for (const username of childBridgeUsernames) {
this.logger.log(`Restarting child bridge ${username}...`);
this.childBridgesService.restartChildBridge(username);
}
}
else {
this.logger.log(`${name} is not running in a child bridge. Performing quick restart of Homebridge process...`);
this.homebridgeIpcService.restartHomebridge();
}
}
}
catch (error) {
this.logger.error(`Failed to update ${name}: ${error.message}`);
try {
this.logger.warn('Attempting fallback restart of Homebridge process...');
this.homebridgeIpcService.restartHomebridge();
}
catch (restartError) {
this.logger.error(`Failed to restart Homebridge: ${restartError.message}`);
}
}
});
return {
ok: true,
name,
version: targetVersion,
};
}
clearInstalledPluginsCache() {
this.installedPluginsCache.del('installed-plugins');
}
async getHomebridgeUiPackage() {
const modules = await this.getInstalledModules();
const uiModule = modules.find(x => x.name === this.configService.name);
if (!uiModule) {
throw new Error('Unable to find Homebridge UI installation.');
}
const pkgJson = await readJson(join(uiModule.installPath, 'package.json'));
const uiPackage = {
name: pkgJson.name,
displayName: pkgJson.displayName || this.pluginNames[pkgJson.name],
private: pkgJson.private || false,
description: (pkgJson.description)
? pkgJson.description.replace(RE_URL, '').trim()
: pkgJson.name,
verifiedPlugin: this.verifiedPlugins.includes(pkgJson.name),
verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkgJson.name),
icon: this.pluginIcons[pkgJson.name]
? `${this.pluginListUrl}${this.pluginIcons[pkgJson.name]}`
: null,
isHbScoped: pkgJson.name.startsWith('@homebridge-plugins/'),
newHbScope: this.newScopePlugins[pkgJson.name],
isUnmaintained: this.unmaintainedPlugins.includes(pkgJson.name),
installedVersion: pkgJson.version || '0.0.1',
globalInstall: (uiModule.path !== this.configService.customPluginPath),
settingsSchema: await pathExists(resolve(uiModule.path, pkgJson.name, 'config.schema.json')),
engines: pkgJson.engines,
installPath: uiModule.path,
funding: (this.verifiedPlugins.includes(pkgJson.name) || this.verifiedPlusPlugins.includes(pkgJson.name))
? pkgJson.funding
: undefined,
directories: pkgJson.directories,
publicPackage: false,
latestVersion: null,
updateAvailable: false,
links: {},
};
await this.getPluginFromNpm(uiPackage, true);
if (!uiPackage.latestVersion) {
return uiPackage;
}
const updatePolicy = this.configService.ui.homebridgeUiUpdatePolicy || 'all';
if (updatePolicy === 'none') {
uiPackage.updateAvailable = false;
uiPackage.latestVersion = null;
}
else if (updatePolicy === 'major') {
const currentMajor = Number.parseInt(uiPackage.installedVersion.split('.')[0], 10);
const versions = await this.getAvailablePluginVersions(this.configService.name);
const sameMajorVersions = Object.keys(versions.versions)
.filter((version) => {
const versionMajor = Number.parseInt(version.split('.')[0], 10);
return versionMajor === currentMajor;
})
.sort(rcompare);
if (sameMajorVersions.length > 0 && gt(sameMajorVersions[0], uiPackage.installedVersion)) {
uiPackage.latestVersion = sameMajorVersions[0];
uiPackage.updateAvailable = true;
uiPackage.updateEngines = versions.versions[sameMajorVersions[0]]?.engines || null;
}
else {
uiPackage.updateAvailable = false;
}
}
else if (updatePolicy === 'beta') {
await this.checkForBetaUpdates(uiPackage, this.configService.name, true);
}
return uiPackage;
}
async getNpmPackage() {
if (this.npmPackage) {
return this.npmPackage;
}
else {
const modules = await this.getInstalledModules();
const npmPkg = modules.find(x => x.name === 'npm');
if (!npmPkg) {
throw new Error('Could not find npm package');
}
const pkgJson = await readJson(join(npmPkg.installPath, 'package.json'));
const npm = await this.parsePackageJson(pkgJson, npmPkg.path);
npm.showUpdateWarning = lt(npm.installedVersion, '9.5.0');
this.npmPackage = npm;
return npm;
}
}
async isPluginBundleAvailable(pluginAction) {
if (this.configService.usePluginBundles === true
&& this.configService.customPluginPath
&& this.configService.strictPluginResolution
&& pluginAction.name !== this.configService.name
&& pluginAction.version !== 'latest') {
try {
const repoVersion = this.getPluginReleaseTag(pluginAction.name);
await firstValueFrom(this.httpService.head(`https://github.com/homebridge/plugins/releases/download/${repoVersion}/${pluginAction.name.replace('/', '@')}-${pluginAction.version}.sha256`));
return true;
}
catch (e) {
return false;
}
}
else {
return false;
}
}
async doPluginBundleUpdate(pluginAction, client) {
const pluginUpgradeInstallScriptPath = join(process.env.UIX_BASE_PATH, 'scripts/upgrade-install-plugin.sh');
const repoVersion = this.getPluginReleaseTag(pluginAction.name);
await this.runNpmCommand([pluginUpgradeInstallScriptPath, pluginAction.name, pluginAction.version, this.configService.customPluginPath, repoVersion], this.configService.storagePath, client, pluginAction.termCols, pluginAction.termRows);
return true;
}
getPluginReleaseTag(pluginName) {
if (pluginName.startsWith('@')) {
return 'v2.0.0';
}
const ch = pluginName.startsWith('homebridge-') ? pluginName.charAt(11) : pluginName.charAt(0);
return ch < 'n' ? 'v2.0.0-1' : 'v2.0.0-2';
}
async isUiUpdateBundleAvailable(pluginAction) {
if ([
'/usr/local/lib/node_modules',
'/usr/lib/node_modules',
'/opt/homebridge/lib/node_modules',
'/var/packages/homebridge/target/app/lib/node_modules',
].includes(dirname(process.env.UIX_BASE_PATH))
&& pluginAction.name === this.configService.name
&& !['latest', 'alpha', 'beta'].includes(pluginAction.version)) {
try {
try {
const withV = `v${pluginAction.version}`;
await firstValueFrom(this.httpService.head(`https://github.com/homebridge/homebridge-config-ui-x/releases/download/${withV}/homebridge-config-ui-x-${pluginAction.version}.tar.gz`));
return withV;
}
catch (e2) {
const withoutV = pluginAction.version;
await firstValueFrom(this.httpService.head(`https://github.com/homebridge/homebridge-config-ui-x/releases/download/${withoutV}/homebridge-config-ui-x-${pluginAction.version}.tar.gz`));
return withoutV;
}
}
catch (e) {
this.logger.error(`Failed to check for bundled update: ${e.message}.`);
return '';
}
}
else {
return '';
}
}
async doUiBundleUpdate(pluginAction, client, githubReleaseName) {
const prefix = dirname(dirname(dirname(process.env.UIX_BASE_PATH)));
const upgradeInstallScriptPath = join(process.env.UIX_BASE_PATH, 'scripts/upgrade-install.sh');
await this.runNpmCommand(this.configService.ui.sudo ? ['npm', 'run', 'upgrade-install', '--', pluginAction.version, prefix, githubReleaseName] : [upgradeInstallScriptPath, pluginAction.version, prefix, githubReleaseName], process.env.UIX_BASE_PATH, client, pluginAction.termCols, pluginAction.termRows);
}
async updateSelfOffline(client) {
client.emit('stdout', yellow(`${this.configService.name} has been scheduled to update on the next container restart.\n\r\n\r`));
await new Promise(res => setTimeout(res, 800));
client.emit('stdout', yellow('The Docker container will now try and restart.\n\r\n\r'));
await new Promise(res => setTimeout(res, 800));
client.emit('stdout', yellow('If you have not started the Docker container with ')
+ red('--restart=always') + yellow(' you may\n\rneed to manually start the container again.\n\r\n\r'));
await new Promise(res => setTimeout(res, 800));
client.emit('stdout', yellow('This process may take several minutes. Please be patient.\n\r'));
await new Promise(res => setTimeout(res, 10000));
await createFile('/homebridge/.uix-upgrade-on-restart');
}
async getPluginConfigSchema(pluginName) {
if (!this.installedPlugins) {
await this.getInstalledPlugins();
}
const plugin = this.installedPlugins.find(x => x.name === pluginName);
if (!plugin) {
throw new NotFoundException();
}
if (!plugin.settingsSchema) {
throw new NotFoundException();
}
let schemaPath;
const i18nPath = plugin.directories?.schemas;
if (i18nPath) {
const lang = this.configService.ui.lang === 'auto' ? 'en' : this.configService.ui.lang;
if (lang && lang !== 'en' && lang !== 'auto') {
const i18nSchemaPath = resolve(plugin.installPath, pluginName, i18nPath, `config.schema.${lang}.json`);
if (existsSync(i18nSchemaPath)) {
schemaPath = i18nSchemaPath;
}
}
}
schemaPath ??= resolve(plugin.installPath, pluginName, 'config.schema.json');
let configSchema = await readJson(schemaPath);
if (configSchema.dynamicSchemaVersion) {
const dynamicSchemaPath = resolve(this.configService.storagePath, `.${pluginName}-v${configSchema.dynamicSchemaVersion}.schema.json`);
this.logger.log(`[${pluginName}] dynamic schema path: ${dynamicSchemaPath}.`);
if (existsSync(dynamicSchemaPath)) {
try {
configSchema = await readJson(dynamicSchemaPath);
this.logger.log(`[${pluginName}] dynamic schema loaded from ${dynamicSchemaPath}.`);
}
catch (e) {
this.logger.error(`[${pluginName}] failed to load dynamic schema from ${dynamicSchemaPath} as ${e.message}.`);
}
}
}
if (pluginName === this.configService.name) {
configSchema.schema.properties.port.default = this.configService.ui.port;
}
if (pluginName === 'homebridge-alexa') {
configSchema.schema.properties.pin.default = this.configService.homebridgeConfig.bridge.pin;
}
if (plugin.displayName) {
configSchema.displayName = plugin.displayName;
}
const childBridgeSchema = {
type: 'object',
notitle: true,
condition: {
functionBody: 'return false',
},
properties: {
name: {
type: 'string',
},
username: {
type: 'string',
},
pin: {
type: 'string',
},
port: {
type: 'integer',
maximum: 65535,
},
setupID: {
type: 'string',
},
manufacturer: {
type: 'string',
},
firmwareRevision: {
type: 'string',
},
model: {
type: 'string',
},
debugModeEnabled: {
type: 'boolean',
},
env: {
type: 'object',
properties: {
DEBUG: {
type: 'string',
},
NODE_OPTIONS: {
type: 'string',
},
},
},
matter: {
type: 'object',
properties: {
port: {
type: 'integer',
maximum: 65535,
},
},
},
},
};
if (configSchema.schema && typeof configSchema.schema.properties === 'object') {
configSchema.schema.properties._bridge = childBridgeSchema;
}
else if (typeof configSchema.schema === 'object') {
configSchema.schema._bridge = childBridgeSchema;
}
return configSchema;
}
async getPluginChangeLog(pluginName) {
await this.getInstalledPlugins();
const plugin = this.installedPlugins.find(x => x.name === pluginName);
if (!plugin) {
throw new NotFoundException();
}
const changeLog = resolve(plugin.installPath, plugin.name, 'CHANGELOG.md');
if (await pathExists(changeLog)) {
return {
changelog: await readFile(changeLog, 'utf8'),
};
}
else {
throw new NotFoundException();
}
}
async getPluginRelease(pluginName) {
let latestVersion = null;
try {
const pkg = (await firstValueFrom((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(pluginName).replace(RE_ENCODED_AT, '@')}`)))).data;
latestVersion = pkg['dist-tags'] ? pkg['dist-tags'].latest : null;
}
catch (e) {
throw new NotFoundException();
}
switch (pluginName) {
case 'homebridge':
case 'homebridge-config-ui-x': {
try {
const release = await firstValueFrom(this.httpService.get(`https://api.github.com/repos/homebridge/${pluginName}/releases/latest`));
const tags = await firstValueFrom(this.httpService.get(`https://api.github.com/repos/homebridge/${pluginName}/tags`));
const changelog = await firstValueFrom(this.httpService.get(`https://raw.githubusercontent.com/homebridge/${pluginName}/refs/tags/${tags.data[0].name}/CHANGELOG.md`));
return {
name: release.data.name,
notes: release.data.body,
changelog: changelog.data,
latestVersion,
};
}
catch {
return {
name: null,
notes: null,
changelog: null,
latestVersion,
};
}
}
default: {
await this.getInstalledPlugins();
const plugin = this.installedPlugins.find(x => x.name === pluginName);
if (!plugin) {
throw new NotFoundException();
}
if (!plugin.links.homepage && !plugin.links.bugs) {
throw new NotFoundException();
}
const repoMatch = plugin.links.homepage?.match(RE_GITHUB_REPO);
const bugsMatch = plugin.links.bugs?.match(RE_GITHUB_REPO);
let match = repoMatch;
if (!repoMatch) {
if (!bugsMatch) {
throw new NotFoundException();
}
match = bugsMatch;
}
const changelogPath = this.pluginChangelogs[pluginName] || '';
const fetchChangelog = async (ref) => {
for (const filename of ['CHANGELOG.md', 'changelog.md']) {
try {
const changelog = await firstValueFrom(this.httpService.get(`https://raw.githubusercontent.com/${match[1]}/${match[2]}/${ref}/${changelogPath}${filename}`));
return changelog.data;
}
catch { }
}
return null;
};
try {
const release = await firstValueFrom(this.httpService.get(`https://api.github.com/repos/${match[1]}/${match[2]}/releases/latest`));
const latestTag = release.data.tag_name;
const isReleaseMatch = latestVersion?.replace(RE_NON_NUMERIC_DOT, '').includes(release.data.tag_name?.replace(RE_NON_NUMERIC_DOT, ''));
const changelogData = await fetchChangelog(`refs/tags/${latestTag}`);
return {
name: isReleaseMatch && release.data.tag_name ? release.data.tag_name : null,
notes: isReleaseMatch && release.data.body ? rele