homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
1,051 lines • 43.8 kB
JavaScript
import { Buffer } from 'node:buffer';
import { execFileSync, execSync, fork } from 'node:child_process';
import { randomInt } from 'node:crypto';
import { chownSync, createReadStream, createWriteStream, existsSync } from 'node:fs';
import { mkdtemp, open, readFile, rename, stat } from 'node:fs/promises';
import { arch, cpus, homedir, platform, release, tmpdir, type } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { StringDecoder } from 'node:string_decoder';
import { fileURLToPath } from 'node:url';
import axios from 'axios';
import { program } from 'commander';
import { mkdirp, pathExists, pathExistsSync, readJson, readJsonSync, remove, writeJson } from 'fs-extra/esm';
import ora from 'ora';
import { gt, gte, parse } from 'semver';
import { networkInterfaceDefault, networkInterfaces } from 'systeminformation';
import { Tail } from 'tail';
import { extract } from 'tar';
import { check as tcpCheck } from 'tcp-port-used';
import { RE_COLON, RE_NON_SCOPED, RE_PLUGIN_NAME, RE_SCOPED, RE_SERVICE_NAME } from '../core/regex.constants.js';
import { DarwinInstaller } from './platforms/darwin.js';
import { FreeBSDInstaller } from './platforms/freebsd.js';
import { LinuxInstaller } from './platforms/linux.js';
import { Win32Installer } from './platforms/win32.js';
process.title = 'hb-service';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export class HomebridgeServiceHelper {
action;
selfPath = __filename;
serviceName = 'Homebridge';
storagePath;
usingCustomStoragePath = false;
allowRunRoot = false;
enableHbServicePluginManagement = false;
asUser;
addGroup;
log;
homebridgeModulePath;
homebridgePackage;
homebridgeBinary;
homebridge;
homebridgeOpts = ['-I'];
homebridgeCustomEnv = {};
uiBinary;
stdout;
docker;
uid;
gid;
uiPort = 8581;
installer;
ipcService;
get logPath() {
return resolve(this.storagePath, 'homebridge.log');
}
constructor() {
this.nodeVersionCheck();
switch (platform()) {
case 'linux':
this.installer = new LinuxInstaller(this);
break;
case 'win32':
this.installer = new Win32Installer(this);
break;
case 'darwin':
this.installer = new DarwinInstaller(this);
break;
case 'freebsd':
this.installer = new FreeBSDInstaller(this);
break;
default:
this.logger(`ERROR: This command is not supported on ${platform()}.`, 'fail');
process.exit(1);
}
program
.allowUnknownOption()
.allowExcessArguments()
.storeOptionsAsProperties(true)
.arguments('[install|uninstall|start|stop|restart|rebuild|run|logs|view|add|remove]')
.option('-P, --plugin-path <path>', '', (p) => {
process.env.UIX_CUSTOM_PLUGIN_PATH = p;
this.homebridgeOpts.push('-P', p);
})
.option('-U, --user-storage-path <path>', '', (p) => {
this.storagePath = p;
this.usingCustomStoragePath = true;
})
.option('-S, --service-name <service name>', 'The name of the homebridge service to install or control', p => this.serviceName = p)
.option('-T, --no-timestamp', '', () => this.homebridgeOpts.push('-T'))
.option('--strict-plugin-resolution', '', () => {
process.env.UIX_STRICT_PLUGIN_RESOLUTION = '1';
})
.option('--port <port>', 'The port to set to the Homebridge UI when installing as a service', p => this.uiPort = Number.parseInt(p, 10))
.option('--user <user>', 'The user account the Homebridge service will be installed as (Linux, FreeBSD, macOS only)', p => this.asUser = p)
.option('--group <group>', 'The group the Homebridge service will be added to (Linux, FreeBSD, macOS only)', p => this.addGroup = p)
.option('--stdout', '', () => this.stdout = true)
.option('--allow-root', '', () => this.allowRunRoot = true)
.option('--docker', '', () => this.docker = true)
.option('--uid <number>', '', i => this.uid = Number.parseInt(i, 10))
.option('--gid <number>', '', i => this.gid = Number.parseInt(i, 10))
.option('-v, --version', 'output the version number', () => this.showVersion())
.action((cmd) => {
this.action = cmd;
})
.parse(process.argv);
this.setEnv();
switch (this.action) {
case 'install': {
this.nvmCheck();
this.logger(`Installing ${this.serviceName} service...`);
this.installer.install();
break;
}
case 'uninstall': {
this.logger(`Removing ${this.serviceName} service...`);
this.installer.uninstall();
break;
}
case 'start': {
this.installer.start();
break;
}
case 'stop': {
this.installer.stop();
break;
}
case 'restart': {
this.logger(`Restarting ${this.serviceName} service...`);
this.installer.restart();
break;
}
case 'rebuild': {
this.logger(`Rebuilding for Node.js ${process.version}...`);
this.installer.rebuild(program.args.includes('--all'));
break;
}
case 'run': {
this.launch();
break;
}
case 'logs': {
this.tailLogs();
break;
}
case 'view': {
this.viewLogs();
break;
}
case 'add': {
this.npmPluginManagement(program.args);
break;
}
case 'remove': {
this.npmPluginManagement(program.args);
break;
}
case 'update-node': {
this.checkForNodejsUpdates(program.args.length === 2 ? program.args[1] : null);
break;
}
case 'update-homebridge': {
this.installer.updateHomebridgePackage();
break;
}
case 'before-start': {
this.installer.beforeStart();
break;
}
case 'status': {
this.checkStatus();
break;
}
default: {
program.outputHelp();
console.log('\nThe hb-service command is provided by homebridge-config-ui-x\n');
console.log('Please provide a command:');
console.log(' install install homebridge as a service');
console.log(' uninstall remove the homebridge service');
console.log(' start start the homebridge service');
console.log(' stop stop the homebridge service');
console.log(' restart restart the homebridge service');
if (this.enableHbServicePluginManagement) {
console.log(' add <plugin>@<version> install a plugin');
console.log(' remove <plugin>@<version> remove a plugin');
}
console.log(' rebuild rebuild ui');
console.log(' rebuild --all rebuild all npm modules (use after updating Node.js)');
console.log(' run run homebridge daemon');
console.log(' logs tails the homebridge service logs');
console.log(' view views the homebridge service logs for 30 seconds');
console.log(' update-node [version] update Node.js');
console.log(' update-homebridge update Homebridge apt package');
console.log('\nSee the wiki for help with hb-service: https://homebridge.io/w/JTtHK \n');
process.exit(1);
}
}
}
logger(msg, level = 'info') {
if (this.action === 'run') {
msg = `\x1B[37m[${new Date().toLocaleString()}]\x1B[0m `
+ `\x1B[36m[HB Supervisor]\x1B[0m ${msg}`;
if (this.log) {
this.log.write(`${msg}\n`);
}
else {
console.log(msg);
}
}
else {
ora()[level](msg);
}
}
setEnv() {
if (!RE_SERVICE_NAME.test(this.serviceName)) {
this.logger('Service name must not contain spaces or special characters.', 'fail');
process.exit(1);
}
if (!this.storagePath) {
if (platform() === 'linux' || platform() === 'freebsd') {
this.storagePath = resolve('/var/lib', this.serviceName.toLowerCase());
}
else {
this.storagePath = resolve(homedir(), `.${this.serviceName.toLowerCase()}`);
}
}
if (process.env.CONFIG_UI_VERSION && process.env.HOMEBRIDGE_VERSION && process.env.QEMU_ARCH) {
if (platform() === 'linux' && ['install', 'uninstall', 'start', 'stop', 'restart', 'logs'].includes(this.action)) {
this.logger(`Sorry, the ${this.action} command is not supported in Docker.`, 'fail');
process.exit(1);
}
}
this.enableHbServicePluginManagement = (process.env.UIX_CUSTOM_PLUGIN_PATH
&& (Boolean(process.env.HOMEBRIDGE_SYNOLOGY_PACKAGE === '1') || Boolean(process.env.HOMEBRIDGE_APT_PACKAGE === '1')));
process.env.UIX_STORAGE_PATH = this.storagePath;
process.env.UIX_CONFIG_PATH = resolve(this.storagePath, 'config.json');
process.env.UIX_BASE_PATH = process.env.UIX_BASE_PATH_OVERRIDE || resolve(__dirname, '../../');
process.env.UIX_SERVICE_MODE = '1';
process.env.UIX_INSECURE_MODE = '1';
}
showVersion() {
const pjson = readJsonSync(resolve(__dirname, '../../', 'package.json'));
console.log(`v${pjson.version}`);
process.exit(0);
}
async startLog() {
if (this.stdout === true) {
this.log = process.stdout;
return;
}
this.logger(`Logging to ${this.logPath}.`);
this.log = createWriteStream(this.logPath, { flags: 'a' });
process.stdout.write = process.stderr.write = this.log.write.bind(this.log);
}
async readConfig() {
return readJson(process.env.UIX_CONFIG_PATH);
}
async truncateLog() {
if (!(await pathExists(this.logPath))) {
return;
}
try {
const currentConfig = await this.readConfig();
const uiConfigBlock = currentConfig.platforms?.find((x) => x.platform === 'config');
const maxSize = uiConfigBlock?.log?.maxSize ?? 1000000;
const truncateSize = uiConfigBlock?.log?.truncateSize ?? 200000;
if (maxSize < 0) {
return;
}
const logStats = await stat(this.logPath);
if (logStats.size < maxSize) {
return;
}
const logStartPosition = logStats.size - truncateSize;
const logBuffer = Buffer.alloc(truncateSize);
const logFileHandle = await open(this.logPath, 'a+');
const corked = this.log && typeof this.log.cork === 'function';
if (corked) {
this.log.cork();
}
try {
await logFileHandle.read(logBuffer, 0, truncateSize, logStartPosition);
await logFileHandle.truncate();
await logFileHandle.write(logBuffer);
}
finally {
await logFileHandle.close();
if (corked) {
this.log.uncork();
}
}
}
catch (e) {
this.logger(`Failed to truncate log file: ${e.message}.`, 'fail');
}
}
async launch() {
if (platform() !== 'win32' && process.getuid() === 0 && !this.allowRunRoot) {
this.logger('The hb-service run command should not be executed as root.');
this.logger('Use the --allow-root flag to force the service to run as the root user.');
process.exit(0);
}
this.logger(`Homebridge storage path: ${this.storagePath}.`);
this.logger(`Homebridge config path: ${process.env.UIX_CONFIG_PATH}.`);
setInterval(() => {
this.truncateLog();
}, (1000 * 60 * 60) * 2);
try {
await this.storagePathCheck();
await this.startLog();
await this.configCheck();
this.logger(`OS: ${type()} ${release()} ${arch()}.`);
this.logger(`Node.js ${process.version} ${process.execPath}.`);
this.homebridgeBinary = await this.findHomebridgePath();
this.logger(`Homebridge path: ${this.homebridgeBinary}.`);
await this.loadHomebridgeStartupOptions();
this.uiBinary = resolve(process.env.UIX_BASE_PATH, 'dist', 'bin', 'standalone.js');
this.logger(`UI path: ${this.uiBinary}.`);
}
catch (e) {
this.logger(e.message);
process.exit(1);
}
this.startExitHandler();
await this.runUi();
if (this.ipcService && this.homebridgePackage) {
this.ipcService.setHomebridgeVersion(this.homebridgePackage.version);
}
if (cpus().length === 1 && arch() === 'arm') {
this.logger('Delaying Homebridge startup by 20 seconds on low powered server.');
setTimeout(() => {
this.runHomebridge();
}, 20000);
}
else {
this.runHomebridge();
}
}
startExitHandler() {
const exitHandler = () => {
this.logger('Stopping services...');
try {
this.homebridge.kill();
}
catch (e) { }
setTimeout(() => {
try {
this.homebridge.kill('SIGKILL');
}
catch (e) { }
process.exit(1282);
}, 7000);
};
process.on('SIGTERM', exitHandler);
process.on('SIGINT', exitHandler);
}
runHomebridge() {
if (!this.homebridgeBinary || !pathExistsSync(this.homebridgeBinary)) {
this.logger('Could not find Homebridge. Make sure you have installed Homebridge using the -g flag then restart.', 'fail');
this.logger('npm install -g --unsafe-perm homebridge', 'fail');
return;
}
if (process.env.UIX_STRICT_PLUGIN_RESOLUTION === '1') {
if (!this.homebridgeOpts.includes('--strict-plugin-resolution')) {
this.homebridgeOpts.push('--strict-plugin-resolution');
}
}
if (this.homebridgeOpts.length) {
this.logger(`Starting Homebridge with extra flags: ${this.homebridgeOpts.join(' ')}.`);
}
if (Object.keys(this.homebridgeCustomEnv).length) {
this.logger(`Starting Homebridge with custom env: ${JSON.stringify(this.homebridgeCustomEnv)}.`);
}
const env = {};
Object.assign(env, process.env);
Object.assign(env, this.homebridgeCustomEnv);
const childProcessOpts = {
env,
silent: true,
};
if (this.allowRunRoot && this.uid && this.gid) {
childProcessOpts.uid = this.uid;
childProcessOpts.gid = this.gid;
}
if (this.docker) {
this.fixDockerPermissions();
}
this.homebridge = fork(this.homebridgeBinary, [
'-C',
'-Q',
'-U',
this.storagePath,
...this.homebridgeOpts,
], childProcessOpts);
if (this.ipcService) {
this.ipcService.setHomebridgeProcess(this.homebridge);
this.ipcService.setHomebridgeVersion(this.homebridgePackage.version);
}
this.logger(`Started Homebridge v${this.homebridgePackage.version} with PID: ${this.homebridge.pid}.`);
const outDecoder = new StringDecoder('utf8');
const errDecoder = new StringDecoder('utf8');
let outBuf = '';
let errBuf = '';
const flushLines = (key) => {
const buf = key === 'out' ? outBuf : errBuf;
let consumed = 0;
let idx = buf.indexOf('\n', consumed);
while (idx !== -1) {
this.log.write(buf.slice(consumed, idx + 1));
consumed = idx + 1;
idx = buf.indexOf('\n', consumed);
}
const remainder = buf.slice(consumed);
if (key === 'out') {
outBuf = remainder;
}
else {
errBuf = remainder;
}
};
this.homebridge.stdout.on('data', (data) => {
outBuf += outDecoder.write(data);
flushLines('out');
});
this.homebridge.stderr.on('data', (data) => {
errBuf += errDecoder.write(data);
flushLines('err');
});
this.homebridge.on('close', (code, signal) => {
outBuf += outDecoder.end();
errBuf += errDecoder.end();
if (outBuf) {
this.log.write(outBuf.endsWith('\n') ? outBuf : `${outBuf}\n`);
outBuf = '';
}
if (errBuf) {
this.log.write(errBuf.endsWith('\n') ? errBuf : `${errBuf}\n`);
errBuf = '';
}
this.handleHomebridgeClose(code, signal);
});
}
handleHomebridgeClose(code, signal) {
this.logger(`Homebridge process ended. Code: ${code}, signal: ${signal}.`);
this.checkForStaleHomebridgeProcess();
this.refreshHomebridgePackage();
setTimeout(() => {
this.logger('Restarting Homebridge...');
this.runHomebridge();
}, 5000);
}
async runUi() {
try {
const main = await import('../main.js');
const ui = await main.app;
this.ipcService = ui.get(main.HomebridgeIpcService);
}
catch (e) {
this.logger('The user interface threw an unhandled error.');
console.error(e);
setTimeout(() => {
process.exit(1);
}, 4500);
if (this.homebridge) {
this.homebridge.kill();
}
}
}
async getNpmGlobalModulesDirectory() {
try {
const npmPrefix = execSync('npm -g prefix', {
env: {
npm_config_loglevel: 'silent',
npm_update_notifier: 'false',
...process.env,
},
}).toString('utf8').trim();
return platform() === 'win32' ? join(npmPrefix, 'node_modules') : join(npmPrefix, 'lib', 'node_modules');
}
catch (e) {
return null;
}
}
async findHomebridgePath() {
const nodeModules = resolve(process.env.UIX_BASE_PATH, '..');
if (await pathExists(resolve(nodeModules, 'homebridge', 'package.json'))) {
this.homebridgeModulePath = resolve(nodeModules, 'homebridge');
}
if (!this.homebridgeModulePath && !(process.env.UIX_STRICT_PLUGIN_RESOLUTION === '1' && process.env.UIX_CUSTOM_PLUGIN_PATH)) {
const globalModules = await this.getNpmGlobalModulesDirectory();
if (globalModules && await pathExists(resolve(globalModules, 'homebridge'))) {
this.homebridgeModulePath = resolve(globalModules, 'homebridge');
}
}
if (!this.homebridgeModulePath && process.env.UIX_CUSTOM_PLUGIN_PATH) {
if (await pathExists(resolve(process.env.UIX_CUSTOM_PLUGIN_PATH, 'homebridge', 'package.json'))) {
this.homebridgeModulePath = resolve(process.env.UIX_CUSTOM_PLUGIN_PATH, 'homebridge');
}
}
if (this.homebridgeModulePath) {
try {
await this.refreshHomebridgePackage();
return resolve(this.homebridgeModulePath, this.homebridgePackage.bin.homebridge);
}
catch (e) {
console.log(e);
}
}
return null;
}
async refreshHomebridgePackage() {
try {
if (await pathExists(this.homebridgeModulePath)) {
this.homebridgePackage = await readJson(join(this.homebridgeModulePath, 'package.json'));
}
else {
this.logger(`Homebridge not longer found at ${this.homebridgeModulePath}.`, 'fail');
this.homebridgeModulePath = undefined;
this.homebridgeBinary = await this.findHomebridgePath();
this.logger(`Found new Homebridge path: ${this.homebridgeBinary}.`);
}
}
catch (e) {
console.log(e);
}
}
nodeVersionCheck() {
if (Number.parseInt(process.versions.modules, 10) < 64) {
this.logger(`Node.js v10.13.0 or greater is required, current: ${process.version}.`, 'fail');
process.exit(1);
}
}
nvmCheck() {
if (process.execPath.includes('nvm') && platform() === 'linux') {
this.logger('WARNING: It looks like you are running Node.js via NVM (Node Version Manager).\n'
+ ' Using hb-service with NVM may not work unless you have configured NVM for the\n'
+ ' user this service will run as. See https://homebridge.io/w/JUZ2g for instructions on how\n'
+ ' to remove NVM, then follow the wiki instructions to install Node.js and Homebridge.', 'warn');
}
}
async printPostInstallInstructions() {
const defaultAdapter = await networkInterfaceDefault();
const defaultInterface = (await networkInterfaces()).find((x) => x.iface === defaultAdapter);
console.log('\nManage Homebridge by going to one of the following in your browser:\n');
console.log(`* http://localhost:${this.uiPort}`);
if (defaultInterface && defaultInterface.ip4) {
console.log(`* http://${defaultInterface.ip4}:${this.uiPort}`);
}
if (defaultInterface && defaultInterface.ip6) {
console.log(`* http://[${defaultInterface.ip6}]:${this.uiPort}`);
}
console.log('');
this.logger('Homebridge setup complete.', 'succeed');
}
async portCheck() {
const inUse = await tcpCheck(this.uiPort);
if (inUse) {
this.logger(`Port ${this.uiPort} is already in use by another process on this host.`, 'fail');
this.logger('You can specify another port using the --port flag, e.g.:', 'fail');
this.logger(`hb-service ${this.action} --port 8581`, 'fail');
process.exit(1);
}
}
async storagePathCheck() {
if (platform() === 'darwin' && !await pathExists(dirname(this.storagePath))) {
this.logger(`Cannot create Homebridge storage directory, base path does not exist: ${dirname(this.storagePath)}.`, 'fail');
process.exit(1);
}
if (!await pathExists(this.storagePath)) {
this.logger(`Creating Homebridge directory: ${this.storagePath}.`);
await mkdirp(this.storagePath);
await this.chownPath(this.storagePath);
}
}
async configCheck() {
let saveRequired = false;
let restartRequired = false;
if (!await pathExists(process.env.UIX_CONFIG_PATH)) {
this.logger(`Creating default config.json: ${process.env.UIX_CONFIG_PATH}.`);
await this.createDefaultConfig();
restartRequired = true;
}
try {
const currentConfig = await this.readConfig();
if (!Array.isArray(currentConfig.platforms)) {
currentConfig.platforms = [];
}
let uiConfigBlock = currentConfig.platforms.find((x) => x.platform === 'config');
if (!uiConfigBlock) {
this.logger(`Adding missing UI platform block to ${process.env.UIX_CONFIG_PATH}.`, 'info');
uiConfigBlock = await this.createDefaultUiConfig();
currentConfig.platforms.push(uiConfigBlock);
saveRequired = true;
restartRequired = true;
}
if (this.action !== 'install' && typeof uiConfigBlock.port !== 'number') {
uiConfigBlock.port = await this.getLastKnownUiPort();
this.logger(`Added missing port number to UI config: ${uiConfigBlock.port}.`, 'info');
saveRequired = true;
restartRequired = true;
}
if (this.action === 'install') {
if (uiConfigBlock.port !== this.uiPort) {
uiConfigBlock.port = this.uiPort;
this.logger(`Homebridge UI port in ${process.env.UIX_CONFIG_PATH} changed to: ${this.uiPort}.`, 'warn');
}
delete uiConfigBlock.restart;
delete uiConfigBlock.sudo;
delete uiConfigBlock.log;
saveRequired = true;
}
if (typeof uiConfigBlock.port !== 'number') {
uiConfigBlock.port = await this.getLastKnownUiPort();
this.logger(`Added missing port number to UI config: ${uiConfigBlock.port}.`, 'info');
saveRequired = true;
restartRequired = true;
}
if (!currentConfig.bridge) {
currentConfig.bridge = await this.generateBridgeConfig();
this.logger('Added missing Homebridge bridge section to the config.json.', 'info');
saveRequired = true;
}
if (!currentConfig.bridge.port) {
currentConfig.bridge.port = await this.generatePort();
this.logger(`Added port to the Homebridge bridge section of the config.json: ${currentConfig.bridge.port}.`, 'info');
saveRequired = true;
}
if ((uiConfigBlock && currentConfig.bridge.port === uiConfigBlock.port) || currentConfig.bridge.port === 8080) {
currentConfig.bridge.port = await this.generatePort();
this.logger(`Bridge port must not be the same as the UI port. Changing bridge port to: ${currentConfig.bridge.port}.`, 'info');
saveRequired = true;
}
if (currentConfig.plugins && Array.isArray(currentConfig.plugins)) {
if (!currentConfig.plugins.includes('homebridge-config-ui-x')) {
currentConfig.plugins.push('homebridge-config-ui-x');
this.logger('Added Homebridge UI to the plugins array in the config.json.', 'info');
saveRequired = true;
}
}
if (saveRequired) {
await writeJson(process.env.UIX_CONFIG_PATH, currentConfig, { spaces: 4 });
}
}
catch (e) {
const backupFile = resolve(this.storagePath, `config.json.invalid.${Date.now().toString()}`);
this.logger(`${process.env.UIX_CONFIG_PATH} does not contain valid JSON.`, 'warn');
this.logger(`Invalid config.json file has been backed up to ${backupFile}.`, 'warn');
await rename(process.env.UIX_CONFIG_PATH, backupFile);
await this.createDefaultConfig();
restartRequired = true;
}
if (restartRequired && this.action === 'run' && await this.isRaspbianImage()) {
this.logger('Restarting process after port number update.', 'info');
process.exit(1);
}
}
async createDefaultConfig() {
await writeJson(process.env.UIX_CONFIG_PATH, {
bridge: await this.generateBridgeConfig(),
accessories: [],
platforms: [
await this.createDefaultUiConfig(),
],
}, { spaces: 4 });
await this.chownPath(process.env.UIX_CONFIG_PATH);
}
async generateBridgeConfig() {
const username = this.generateUsername();
const port = await this.generatePort();
const name = `Homebridge ${username.substring(username.length - 5).replace(RE_COLON, '')}`;
const pin = this.generatePin();
const advertiser = await this.isAvahiDaemonRunning() ? 'avahi' : 'bonjour-hap';
return {
name,
username,
port,
pin,
advertiser,
};
}
async createDefaultUiConfig() {
return {
name: 'Config',
port: this.action === 'install' ? this.uiPort : await this.getLastKnownUiPort(),
platform: 'config',
};
}
async isRaspbianImage() {
return platform() === 'linux' && await pathExists('/etc/hb-ui-port');
}
async getLastKnownUiPort() {
if (await this.isRaspbianImage()) {
const lastPort = Number.parseInt((await readFile('/etc/hb-ui-port', 'utf8')), 10);
if (!Number.isNaN(lastPort) && lastPort <= 65535) {
return lastPort;
}
}
const envPort = Number.parseInt(process.env.HOMEBRIDGE_CONFIG_UI_PORT, 10);
if (!Number.isNaN(envPort) && envPort <= 65535) {
return envPort;
}
return this.uiPort;
}
generatePin() {
let code = `${randomInt(10000000, 100000000)}`;
code = code.split('');
code.splice(3, 0, '-');
code.splice(6, 0, '-');
code = code.join('');
return code;
}
generateUsername() {
const hexDigits = '0123456789ABCDEF';
let username = '0E:';
for (let i = 0; i < 5; i += 1) {
username += hexDigits.charAt(randomInt(0, 16));
username += hexDigits.charAt(randomInt(0, 16));
if (i !== 4) {
username += ':';
}
}
return username;
}
async generatePort() {
const randomPort = () => randomInt(51000, 52001);
let port = randomPort();
while (await tcpCheck(port)) {
port = randomPort();
}
return port;
}
avahiDaemonRunning;
async isAvahiDaemonRunning() {
if (this.avahiDaemonRunning !== undefined) {
return this.avahiDaemonRunning;
}
if (platform() !== 'linux') {
this.avahiDaemonRunning = false;
return false;
}
if (!await pathExists('/etc/avahi/avahi-daemon.conf') || !await pathExists('/usr/bin/systemctl')) {
this.avahiDaemonRunning = false;
return false;
}
try {
if (await pathExists('/usr/lib/systemd/system/avahi.service')) {
execSync('systemctl is-active --quiet avahi 2> /dev/null');
this.avahiDaemonRunning = true;
return true;
}
else if (await pathExists('/lib/systemd/system/avahi-daemon.service')) {
execSync('systemctl is-active --quiet avahi-daemon 2> /dev/null');
this.avahiDaemonRunning = true;
return true;
}
else {
this.avahiDaemonRunning = false;
return false;
}
}
catch (e) {
this.avahiDaemonRunning = false;
return false;
}
}
async chownPath(pathToChown) {
if (platform() !== 'win32' && process.getuid() === 0) {
const { uid, gid } = await this.installer.getId();
chownSync(pathToChown, uid, gid);
}
}
async checkForStaleHomebridgeProcess() {
if (platform() === 'win32') {
return;
}
try {
const currentConfig = await this.readConfig();
if (!currentConfig.bridge || !currentConfig.bridge.port) {
return;
}
if (!await tcpCheck(Number.parseInt(currentConfig.bridge.port.toString(), 10))) {
return;
}
const pid = Number.parseInt(this.installer.getPidOfPort(Number.parseInt(currentConfig.bridge.port.toString(), 10)), 10);
if (!pid) {
return;
}
this.logger(`Found stale Homebridge process running on port: ${currentConfig.bridge.port}, with PID: ${pid}, killing...`);
process.kill(pid, 'SIGKILL');
}
catch (e) {
}
}
async tailLogs() {
if (!existsSync(this.logPath)) {
this.logger(`Log file does not exist at expected location: ${this.logPath}.`, 'fail');
process.exit(1);
}
const logStats = await stat(this.logPath);
const logStartPosition = logStats.size <= 200000 ? 0 : logStats.size - 200000;
const logStream = createReadStream(this.logPath, { start: logStartPosition });
logStream.on('data', (buffer) => {
process.stdout.write(buffer);
});
logStream.on('end', () => {
logStream.close();
});
const tail = new Tail(this.logPath, {
fromBeginning: false,
useWatchFile: true,
fsWatchOptions: {
interval: 200,
},
});
tail.on('line', console.log);
}
async viewLogs() {
this.installer.viewLogs();
if (!existsSync(this.logPath)) {
this.logger(`Log file does not exist at expected location: ${this.logPath}.`, 'fail');
process.exit(1);
}
const logStats = await stat(this.logPath);
const logStartPosition = logStats.size <= 200000 ? 0 : logStats.size - 200000;
const logStream = createReadStream(this.logPath, { start: logStartPosition });
logStream.on('data', (buffer) => {
process.stdout.write(buffer);
});
logStream.on('end', () => {
logStream.close();
});
const tail = new Tail(this.logPath, {
fromBeginning: false,
useWatchFile: true,
fsWatchOptions: {
interval: 200,
},
});
tail.on('line', console.log);
setTimeout(() => {
tail.unwatch();
}, 30000);
}
get homebridgeStartupOptionsPath() {
return resolve(this.storagePath, '.uix-hb-service-homebridge-startup.json');
}
async loadHomebridgeStartupOptions() {
try {
if (await pathExists(this.homebridgeStartupOptionsPath)) {
const homebridgeStartupOptions = await readJson(this.homebridgeStartupOptionsPath);
if (homebridgeStartupOptions.debugMode && !this.homebridgeOpts.includes('-D')) {
this.homebridgeOpts.push('-D');
}
if (homebridgeStartupOptions.keepOrphans && !this.homebridgeOpts.includes('-K')) {
this.homebridgeOpts.push('-K');
}
if (homebridgeStartupOptions.insecureMode === false && this.homebridgeOpts.includes('-I')) {
this.homebridgeOpts.splice(this.homebridgeOpts.findIndex(x => x === '-I'), 1);
process.env.UIX_INSECURE_MODE = '0';
}
Object.assign(this.homebridgeCustomEnv, homebridgeStartupOptions.env);
}
else if (this.docker) {
if (process.env.HOMEBRIDGE_DEBUG === '1' && !this.homebridgeOpts.includes('-D')) {
this.homebridgeOpts.push('-D');
}
if (process.env.HOMEBRIDGE_INSECURE !== '1' && this.homebridgeOpts.includes('-I')) {
this.homebridgeOpts.splice(this.homebridgeOpts.findIndex(x => x === '-I'), 1);
process.env.UIX_INSECURE_MODE = '0';
}
}
}
catch (e) {
this.logger(`Failed to load startup options as ${e.message}.`);
}
}
fixDockerPermissions() {
try {
execSync(`chown -R ${this.uid}:${this.gid} "${this.storagePath}"`);
}
catch (e) {
}
}
async checkForNodejsUpdates(requestedVersion) {
const versionList = (await axios.get('https://nodejs.org/dist/index.json')).data;
if (!Array.isArray(versionList)) {
this.logger('Failed to check for Node.js updates.', 'fail');
return { update: false };
}
const currentLts = versionList.filter(x => x.lts)[0];
if (requestedVersion) {
const wantedVersion = versionList.find(x => x.version.startsWith(`v${requestedVersion}`));
if (wantedVersion) {
if (!gte(wantedVersion.version, '22.12.0')) {
this.logger('Refusing to install Node.js version lower than v22.12.0.', 'fail');
return { update: false };
}
this.logger(`Installing Node.js ${wantedVersion.version} over ${process.version}...`, 'info');
return this.installer.updateNodejs({
target: wantedVersion.version,
rebuild: wantedVersion.modules !== process.versions.modules,
});
}
else {
this.logger(`v${requestedVersion} is not a valid Node.js version.`, 'info');
return { update: false };
}
}
if (gt(currentLts.version, process.version)) {
this.logger(`Updating Node.js from ${process.version} to ${currentLts.version}...`, 'info');
return this.installer.updateNodejs({
target: currentLts.version,
rebuild: currentLts.modules !== process.versions.modules,
});
}
const currentMajor = parse(process.version).major;
const latestVersion = versionList.filter(x => parse(x.version).major === currentMajor)[0];
if (gt(latestVersion.version, process.version)) {
this.logger(`Updating Node.js from ${process.version} to ${latestVersion.version}...`, 'info');
return this.installer.updateNodejs({
target: latestVersion.version,
rebuild: latestVersion.modules !== process.versions.modules,
});
}
this.logger(`Node.js ${process.version} already up-to-date.`);
return { update: false };
}
async downloadNodejs(downloadUrl) {
const spinner = ora(`Downloading ${downloadUrl}`).start();
try {
const tempDir = await mkdtemp(join(tmpdir(), 'node'));
const tempFilePath = join(tempDir, 'node.tar.gz');
const tempFile = createWriteStream(tempFilePath);
await axios.get(downloadUrl, { responseType: 'stream' })
.then((response) => {
return new Promise((res, rej) => {
response.data.pipe(tempFile).on('finish', () => {
return res(tempFile);
}).on('error', (err) => {
return rej(err);
});
});
});
spinner.succeed('Download complete.');
return tempFilePath;
}
catch (e) {
spinner.fail(e.message);
process.exit(1);
}
}
async extractNodejs(targetVersion, extractConfig) {
const spinner = ora(`Installing Node.js ${targetVersion}`).start();
try {
await extract(extractConfig);
spinner.succeed(`Installed Node.js ${targetVersion}`);
}
catch (e) {
spinner.fail(e.message);
process.exit(1);
}
}
async removeNpmPackage(npmInstallPath) {
if (!await pathExists(npmInstallPath)) {
return;
}
const spinner = ora(`Cleaning up npm at ${npmInstallPath}...`).start();
try {
await remove(npmInstallPath);
spinner.succeed(`Cleaned up npm at ${npmInstallPath}`);
}
catch (e) {
spinner.fail(e.message);
}
}
async checkStatus() {
this.logger(`Testing hb-service is running on port ${this.uiPort}...`);
try {
const res = await axios.get(`http://localhost:${this.uiPort}/api`);
if (res.data === 'Hello World!') {
this.logger('Homebridge UI running.', 'succeed');
}
else {
this.logger('Unexpected response.', 'fail');
process.exit(1);
}
}
catch (e) {
this.logger('Homebridge UI not running.', 'fail');
process.exit(1);
}
}
parseNpmPackageString(input) {
const m = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input);
if (!m) {
this.logger('Invalid plugin name.', 'fail');
process.exit(1);
}
return {
name: m[1] || '',
version: m[2] || 'latest',
path: m[3] || '',
};
}
async npmPluginManagement(args) {
if (!this.enableHbServicePluginManagement) {
this.logger('Plugin management is not supported on your platform using hb-service.', 'fail');
process.exit(1);
}
if (args.length === 1) {
this.logger('Plugin name required.', 'fail');
process.exit(1);
}
const action = args[0];
const target = this.parseNpmPackageString(args.at(-1));
if (!target.name) {
this.logger('Invalid plugin name.', 'fail');
process.exit(1);
}
if (!RE_PLUGIN_NAME.test(target.name)) {
this.logger('Invalid plugin name.', 'fail');
process.exit(1);
}
const RE_NPM_VERSION_OR_TAG = /^[\w.\-^~>=<*|+]+$/;
if (!RE_NPM_VERSION_OR_TAG.test(target.version)) {
this.logger(`Invalid plugin version "${target.version}".`, 'fail');
process.exit(1);
}
const cwd = dirname(process.env.UIX_CUSTOM_PLUGIN_PATH);
if (!await pathExists(cwd)) {
this.logger(`Path does not exist: ${cwd}.`, 'fail');
}
const npmArgs = ['--prefix', cwd, action, action === 'add' ? `${target.name}@${target.version}` : target.name];
this.logger(`CMD: npm ${npmArgs.join(' ')}`, 'info');
try {
execFileSync('npm', npmArgs, {
cwd,
stdio: 'inherit',
});
this.logger(`Installed ${target.name}@${target.version}.`, 'succeed');
}
catch (e) {
this.logger(`Plugin installation failed as ${e.message}.`, 'fail');
}
}
}
function bootstrap() {
return new HomebridgeServiceHelper();
}
bootstrap();
//# sourceMappingURL=hb-service.js.map