homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
314 lines • 13.3 kB
JavaScript
import { execFileSync, execSync } from 'node:child_process';
import { existsSync, unlinkSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { homedir, release, userInfo } from 'node:os';
import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { pathExists, remove } from 'fs-extra/esm';
import { gte, lt } from 'semver';
import { RE_OS_USERNAME } from '../../core/regex.constants.js';
import { BasePlatform } from '../base-platform.js';
export class DarwinInstaller extends BasePlatform {
user;
get plistName() {
return `com.${this.hbService.serviceName.toLowerCase()}.server`;
}
get plistPath() {
return resolve('/Library/LaunchDaemons/', `${this.plistName}.plist`);
}
async install() {
this.checkForRoot();
this.fixStoragePath();
await this.hbService.portCheck();
await this.checkGlobalNpmAccess();
await this.hbService.storagePathCheck();
await this.hbService.configCheck();
try {
await this.createLaunchAgent();
await this.start();
await this.hbService.printPostInstallInstructions();
}
catch (e) {
console.error(e.toString());
this.hbService.logger('ERROR: Failed Operation', 'fail');
}
}
async uninstall() {
this.checkForRoot();
await this.stop();
try {
if (existsSync(this.plistPath)) {
this.hbService.logger(`Removed ${this.hbService.serviceName} Service`, 'succeed');
unlinkSync(this.plistPath);
}
else {
this.hbService.logger(`Could not find installed ${this.hbService.serviceName} Service.`, 'fail');
}
}
catch (e) {
console.error(e.toString());
this.hbService.logger('ERROR: Failed Operation', 'fail');
}
}
async start() {
this.checkForRoot();
try {
this.hbService.logger(`Starting ${this.hbService.serviceName} Service...`);
execFileSync('launchctl', ['load', '-w', this.plistPath]);
this.hbService.logger(`${this.hbService.serviceName} Started`, 'succeed');
}
catch (e) {
this.hbService.logger(`Failed to start ${this.hbService.serviceName}`, 'fail');
}
}
async stop() {
this.checkForRoot();
try {
this.hbService.logger(`Stopping ${this.hbService.serviceName} Service...`);
execFileSync('launchctl', ['unload', '-w', this.plistPath]);
this.hbService.logger(`${this.hbService.serviceName} Stopped`, 'succeed');
}
catch (e) {
this.hbService.logger(`Failed to stop ${this.hbService.serviceName}`, 'fail');
}
}
async restart() {
this.checkForRoot();
await this.stop();
await new Promise(resolve => setTimeout(resolve, 2000));
await this.start();
}
async rebuild(all = false) {
try {
if (!this.isPackage()) {
this.checkForRoot();
}
const targetNodeVersion = execSync('node -v').toString('utf8').trim();
const npmGlobalPath = execSync('/bin/echo -n "$(npm -g prefix)/lib/node_modules"', {
env: {
npm_config_loglevel: 'silent',
npm_update_notifier: 'false',
...process.env,
},
}).toString('utf8');
execSync('npm rebuild --unsafe-perm', {
cwd: process.env.UIX_BASE_PATH,
stdio: 'inherit',
});
this.hbService.logger(`Rebuilt homebridge-config-ui-x for Node.js ${targetNodeVersion}.`, 'succeed');
if (all === true) {
try {
execSync('npm rebuild --unsafe-perm', {
cwd: npmGlobalPath,
stdio: 'inherit',
});
this.hbService.logger(`Rebuilt plugins in ${npmGlobalPath} for Node.js ${targetNodeVersion}.`, 'succeed');
}
catch (e) {
this.hbService.logger('Could not rebuild all modules - check Homebridge logs.', 'warn');
}
}
await this.setNpmPermissions(npmGlobalPath);
}
catch (e) {
console.error(e.toString());
this.hbService.logger('ERROR: Failed Operation', 'fail');
}
}
async getId() {
if ((process.getuid() === 0 && this.hbService.asUser) || process.env.SUDO_USER) {
const uid = execSync(`id -u ${this.hbService.asUser || process.env.SUDO_USER}`).toString('utf8');
const gid = execSync(`id -g ${this.hbService.asUser || process.env.SUDO_USER}`).toString('utf8');
return {
uid: Number.parseInt(uid, 10),
gid: Number.parseInt(gid, 10),
};
}
else {
return {
uid: userInfo().uid,
gid: userInfo().gid,
};
}
}
getPidOfPort(port) {
try {
return execSync(`lsof -n -iTCP:${port} -sTCP:LISTEN -t 2> /dev/null`).toString('utf8').trim();
}
catch (e) {
return null;
}
}
checkForRoot() {
if (process.getuid() !== 0) {
this.hbService.logger('ERROR: This command must be executed using sudo on macOS', 'fail');
this.hbService.logger(`sudo hb-service ${this.hbService.action}`, 'fail');
process.exit(1);
}
if (!process.env.SUDO_USER && !this.hbService.asUser) {
this.hbService.logger('ERROR: Could not detect user. Pass in the user you want to run Homebridge as using the --user flag eg.', 'fail');
this.hbService.logger(`sudo hb-service ${this.hbService.action} --user your-user`, 'fail');
process.exit(1);
}
this.user = this.hbService.asUser || process.env.SUDO_USER;
}
fixStoragePath() {
if (!this.hbService.usingCustomStoragePath) {
this.hbService.storagePath = resolve(this.getUserHomeDir(), `.${this.hbService.serviceName.toLowerCase()}`);
}
}
getUserHomeDir() {
if (!RE_OS_USERNAME.test(this.user)) {
this.hbService.logger(`WARNING: Refusing to resolve home directory — invalid username "${this.user}".`, 'warn');
return homedir();
}
try {
const output = execFileSync('dscl', ['.', '-read', `/Users/${this.user}`, 'NFSHomeDirectory']).toString('utf8').trim();
const match = output.match(/NFSHomeDirectory: (.+)$/m);
if (match && match[1] && match[1].charAt(0) === '/') {
return match[1].trim();
}
throw new Error(`Could not resolve user home directory for ${this.user}`);
}
catch (e) {
return homedir();
}
}
async updateNodejs(job) {
this.checkForRoot();
if (!['x64', 'arm64'].includes(process.arch)) {
this.hbService.logger(`Architecture not supported: ${process.arch}.`, 'fail');
process.exit(1);
}
if (process.arch === 'arm64' && lt(job.target, '18.0.0')) {
this.hbService.logger('macOS M1 / arm64 support is only available from Node.js v18 or later', 'fail');
process.exit(1);
}
if (lt(release(), '19.0.0') && gte(job.target, '18.0.0')) {
this.hbService.logger('macOS Catalina 10.15 or later is required to install Node.js v18 or later', 'fail');
process.exit(1);
}
const downloadUrl = `https://nodejs.org/dist/${job.target}/node-${job.target}-darwin-${process.arch}.tar.gz`;
const targetPath = dirname(dirname(process.execPath));
if (targetPath !== '/usr/local' && !targetPath.startsWith('/Library/Application Support/Homebridge/node-')) {
this.hbService.logger(`Cannot update Node.js on your system. Non-standard installation path detected: ${targetPath}`, 'fail');
process.exit(1);
}
this.hbService.logger(`Target: ${targetPath}`);
try {
const archivePath = await this.hbService.downloadNodejs(downloadUrl);
const extractConfig = {
file: archivePath,
cwd: targetPath,
strip: 1,
preserveOwner: false,
unlink: true,
};
await this.hbService.removeNpmPackage(resolve(targetPath, 'lib', 'node_modules', 'npm'));
await this.hbService.extractNodejs(job.target, extractConfig);
await remove(archivePath);
await this.rebuild(true);
if (await pathExists(this.plistPath)) {
await this.restart();
}
else {
this.hbService.logger('Please restart Homebridge for the changes to take effect.', 'warn');
}
}
catch (e) {
this.hbService.logger(`Failed to update Node.js: ${e.message}`, 'fail');
process.exit(1);
}
}
async checkGlobalNpmAccess() {
const npmGlobalPath = execSync('/bin/echo -n "$(npm -g prefix)/lib/node_modules"', {
env: {
npm_config_loglevel: 'silent',
npm_update_notifier: 'false',
...process.env,
},
}).toString('utf8');
const { uid, gid } = await this.getId();
try {
execSync(`test -w "${npmGlobalPath}"`, {
uid,
gid,
});
execSync('test -w "$(dirname $(which npm))"', {
uid,
gid,
});
}
catch (e) {
await this.setNpmPermissions(npmGlobalPath);
}
}
async setNpmPermissions(npmGlobalPath) {
if (this.isPackage()) {
return;
}
try {
execSync(`chown -R ${this.user}:admin "${npmGlobalPath}"`);
execSync(`chown -R ${this.user}:admin "$(dirname $(which npm))"`);
}
catch (e) {
this.hbService.logger(`ERROR: User "${this.user}" does not have write access to the global npm modules path.`, 'fail');
this.hbService.logger('You can fix this issue by running the following commands:', 'fail');
console.log('');
console.log(`sudo chown -R ${this.user}:admin "${npmGlobalPath}"`);
console.log(`sudo chown -R ${this.user}:admin "$(dirname $(which npm))"`);
console.log('');
this.hbService.logger('Once you have done this run the hb-service install command again to complete your installation.', 'fail');
process.exit(1);
}
}
isPackage() {
return (Boolean(process.env.HOMEBRIDGE_MACOS_PACKAGE === '1'));
}
async createLaunchAgent() {
const plistFileContents = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
' <key>RunAtLoad</key>',
' <true/>',
' <key>KeepAlive</key>',
' <true/>',
' <key>Label</key>',
` <string>${this.plistName}</string>`,
' <key>ProgramArguments</key>',
' <array>',
` <string>${process.execPath}</string>`,
` <string>${this.hbService.selfPath}</string>`,
' <string>run</string>',
' <string>-I</string>',
' <string>-U</string>',
` <string>${this.hbService.storagePath}</string>`,
' </array>',
' <key>WorkingDirectory</key>',
` <string>${this.hbService.storagePath}</string>`,
' <key>StandardOutPath</key>',
` <string>${this.hbService.storagePath}/homebridge.log</string>`,
' <key>StandardErrorPath</key>',
` <string>${this.hbService.storagePath}/homebridge.log</string>`,
' <key>UserName</key>',
` <string>${this.user}</string>`,
' <key>EnvironmentVariables</key>',
' <dict>',
' <key>PATH</key>',
` <string>${dirname(process.execPath)}:/opt/homebridge/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>`,
' <key>HOME</key>',
` <string>${this.getUserHomeDir()}</string>`,
' <key>UIX_STORAGE_PATH</key>',
` <string>${this.hbService.storagePath}</string>`,
' <key>HOMEBRIDGE_CONFIG_UI_TERMINAL</key>',
' <string>1</string>',
' </dict>',
'</dict>',
'</plist>',
].filter(x => x).join('\n');
await writeFile(this.plistPath, plistFileContents);
}
}
//# sourceMappingURL=darwin.js.map