UNPKG

homebridge-config-ui-x

Version:

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

314 lines 13.3 kB
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