UNPKG

homebridge-config-ui-x

Version:

A web based management, configuration and control platform for Homebridge.

1,025 lines • 74.3 kB
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