diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c7eb0ff087..906761f7bb 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -4,6 +4,9 @@ on: [push] jobs: linux64: runs-on: ubuntu-20.04 + outputs: + version: ${{ steps.filenames.outputs.longversion }} + do-docker: ${{ steps.upload.outputs.branch }} steps: - uses: actions/checkout@v3 with: @@ -67,13 +70,6 @@ jobs: api-target: 'linux-tgz' api-secret: ${{ secrets.BITFOCUS_API_PROJECT_SECRET }} - - name: Report docker build is needed - if: ${{ steps.upload.outputs.branch }} - uses: actions/upload-artifact@v3 - with: - name: do-docker-build - path: dist/BUILD - linux-arm64: runs-on: ubuntu-20.04 steps: @@ -345,25 +341,14 @@ jobs: IMAGE_NAME: companion steps: - - name: Check if build is required - id: build-check - uses: xSAVIKx/artifact-exists-action@v0 - with: - name: do-docker-build - - uses: actions/checkout@v3 - if: steps.build-check.outputs.exists == 'true' - - - uses: actions/download-artifact@v3 - if: steps.build-check.outputs.exists == 'true' - with: - name: do-docker-build + if: ${{ needs.linux64.outputs.do-docker }} - name: Determine target image name - if: steps.build-check.outputs.exists == 'true' + if: ${{ needs.linux64.outputs.do-docker }} id: image-name run: | - BUILD_VERSION=$(cat BUILD) + BUILD_VERSION=${{ needs.linux64.outputs.version }} IMAGE_ID=ghcr.io/${{ github.repository }}/$IMAGE_NAME diff --git a/lib/Instance/Controller.js b/lib/Instance/Controller.js index b548fbc9d2..87fe261f2b 100644 --- a/lib/Instance/Controller.js +++ b/lib/Instance/Controller.js @@ -219,6 +219,15 @@ class Instance extends CoreBase { return undefined } + getManifestForInstance(id) { + const config = this.store.db[id] + if (!config) return null + + const moduleManifest = this.modules.getModuleManifest(config.instance_type) + + return moduleManifest.manifest + } + enableDisableInstance(id, state) { if (this.store.db[id]) { const label = this.store.db[id].label diff --git a/lib/Instance/Modules.js b/lib/Instance/Modules.js index cf9331a885..e30b9a30fa 100644 --- a/lib/Instance/Modules.js +++ b/lib/Instance/Modules.js @@ -223,6 +223,15 @@ class InstanceModules extends CoreBase { return result } + /** + * + * @access public + * @param {string} module_id + */ + getModuleManifest(module_id) { + return this.known_modules[module_id] + } + /** * Load the help markdown file for a specified module_id * @access public diff --git a/lib/Service/BonjourDiscovery.js b/lib/Service/BonjourDiscovery.js new file mode 100644 index 0000000000..612ab704cd --- /dev/null +++ b/lib/Service/BonjourDiscovery.js @@ -0,0 +1,184 @@ +import { isEqual } from 'lodash-es' +import ServiceBase from './Base.js' +import { Bonjour, Browser } from 'bonjour-service' +import { nanoid } from 'nanoid' + +function BonjourRoom(id) { + return `bonjour:${id}` +} + +/** + * Class providing Bonjour discovery for modules. + * + * @extends ServiceBonjour + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.2.0 + * @copyright 2022 Bitfocus AS + * @license + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for Companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + */ +class ServiceBonjourDiscovery extends ServiceBase { + /** + * Active browsers running + */ + #browsers = new Map() + + /** + * @param {Registry} registry - the application core + */ + constructor(registry) { + super(registry, 'bonjour-discovery', 'Service/BonjourDiscovery', undefined, undefined) + + this.init() + } + + /** + * Start the service if it is not already running + * @access protected + */ + listen() { + if (this.server === undefined) { + try { + this.server = new Bonjour() + + this.logger.info('Listening for Bonjour messages') + } catch (e) { + this.logger.error(`Could not launch: ${e.message}`) + } + } + } + + /** + * Close the socket before deleting it + * @access protected + */ + close() { + this.server.destroy() + } + + /** + * Setup a new socket client's events + * @param {SocketIO} client - the client socket + * @access public + */ + clientConnect(client) { + client.on('disconnect', () => { + // Ensure any sessions the client was part of are cleaned up + for (const subId of this.#browsers.keys()) { + this.#removeClientFromSession(client.id, subId) + } + }) + + client.onPromise('bonjour:subscribe', (connectionId, queryId) => + this.#joinOrCreateSession(client, connectionId, queryId) + ) + client.on('bonjour:unsubscribe', (subId) => this.#leaveSession(client, subId)) + } + + #convertService(id, svc) { + return { + subId: id, + fqdn: svc.fqdn, + name: svc.name, + port: svc.port, + // type: svc.type, + // protocol: svc.protocol, + // txt: svc.txt, + // host: svc.host, + addresses: svc.addresses, + } + } + + #joinOrCreateSession(client, connectionId, queryId) { + const manifest = this.instance.getManifestForInstance(connectionId) + const bonjourQuery = manifest?.bonjourQueries?.[queryId] + if (!bonjourQuery) throw new Error('Missing bonjour query') + + const filter = { + type: bonjourQuery.type, + protocol: bonjourQuery.protocol, + txt: bonjourQuery.txt, + } + if (typeof filter.type !== 'string' || !filter.type) throw new Error('Invalid type for bonjour query') + if (typeof filter.protocol !== 'string' || !filter.protocol) throw new Error('Invalid protocol for bonjour query') + + // Find existing browser + for (const [id, session] of this.#browsers.entries()) { + if (isEqual(session.filter, filter)) { + session.clientIds.add(client.id) + + client.join(BonjourRoom(id)) + this.logger.info(`Client ${client.id} joined ${id}`) + + // After this message, send already known services to the client + setImmediate(() => { + for (const svc of session.browser.services) { + client.emit(`bonjour:service:up`, this.#convertService(id, svc)) + } + }) + + return id + } + } + + // Create new browser + this.logger.info(`Starting discovery of: ${JSON.stringify(filter)}`) + const browser = this.server.find(filter) + const id = nanoid() + const room = BonjourRoom(id) + this.#browsers.set(id, { + browser, + filter, + clientIds: new Set([client.id]), + }) + + // Setup event handlers + browser.on('up', (svc) => { + this.io.emitToRoom(room, `bonjour:service:up`, this.#convertService(id, svc)) + }) + browser.on('down', (svc) => { + this.io.emitToRoom(room, `bonjour:service:down`, this.#convertService(id, svc)) + }) + + // Report to client + client.join(room) + this.logger.info(`Client ${client.id} joined ${id}`) + return id + } + + #leaveSession(client, subId) { + this.logger.info(`Client ${client.id} left ${subId}`) + client.leave(BonjourRoom(subId)) + + this.#removeClientFromSession(client.id, subId) + } + + #removeClientFromSession(clientId, subId) { + const session = this.#browsers.get(subId) + if (!session || !session.clientIds.delete(clientId)) return + + // Cleanup after a timeout, as restarting the same query immediately causes it to fail + setTimeout(() => { + if (this.#browsers.has(subId) && session.clientIds.size === 0) { + this.logger.info(`Stopping discovery of: ${JSON.stringify(session.filter)}`) + + this.#browsers.delete(subId) + + session.browser.stop() + } + }, 500) + } +} + +export default ServiceBonjourDiscovery diff --git a/lib/Service/Controller.js b/lib/Service/Controller.js index fa6ad49933..897b26ea8a 100644 --- a/lib/Service/Controller.js +++ b/lib/Service/Controller.js @@ -1,5 +1,6 @@ import ServiceApi from './Api.js' import ServiceArtnet from './Artnet.js' +import ServiceBonjourDiscovery from './BonjourDiscovery.js' import ServiceElgatoPlugin from './ElgatoPlugin.js' import ServiceEmberPlus from './EmberPlus.js' import ServiceHttps from './Https.js' @@ -48,6 +49,7 @@ class ServiceController { this.satellite = new ServiceSatellite(registry) this.elgatoPlugin = new ServiceElgatoPlugin(registry) this.videohubPanel = new ServiceVideohubPanel(registry) + this.bonjourDiscovery = new ServiceBonjourDiscovery(registry) } /** @@ -58,6 +60,7 @@ class ServiceController { */ updateUserConfig(key, value) { this.artnet.updateUserConfig(key, value) + this.bonjourDiscovery.updateUserConfig(key, value) this.elgatoPlugin.updateUserConfig(key, value) this.emberplus.updateUserConfig(key, value) this.https.updateUserConfig(key, value) @@ -69,6 +72,15 @@ class ServiceController { this.udp.updateUserConfig(key, value) this.videohubPanel.updateUserConfig(key, value) } + + /** + * Setup a new socket client's events + * @param {SocketIO} client - the client socket + * @access public + */ + clientConnect(client) { + this.bonjourDiscovery.clientConnect(client) + } } export default ServiceController diff --git a/lib/UI/Handler.js b/lib/UI/Handler.js index d56facd678..9671268b38 100644 --- a/lib/UI/Handler.js +++ b/lib/UI/Handler.js @@ -158,6 +158,7 @@ class UIHandler { this.registry.surfaces.clientConnect(client) this.registry.instance.clientConnect(client) this.registry.cloud.clientConnect(client) + this.registry.services.clientConnect(client) client.on('disconnect', () => { this.logger.debug('socket ' + client.id + ' disconnected') diff --git a/package.json b/package.json index f346ce236f..0cfba927f8 100755 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@sentry/tracing": "^7.58.1", "archiver": "^5.3.1", "body-parser": "^1.20.2", + "bonjour-service": "^1.1.1", "bufferutil": "^4.0.7", "colord": "^2.9.3", "commander": "^11.0.0", diff --git a/webui/src/Components/BonjourDeviceInputField.jsx b/webui/src/Components/BonjourDeviceInputField.jsx new file mode 100644 index 0000000000..4b710e0fc6 --- /dev/null +++ b/webui/src/Components/BonjourDeviceInputField.jsx @@ -0,0 +1,117 @@ +import { createContext, useRef, useState } from 'react' +import { useContext } from 'react' +import { useMemo, useEffect } from 'react' +import { SocketContext, socketEmitPromise } from '../util' +import { DropdownInputField } from './DropdownInputField' + +export const MenuPortalContext = createContext(null) + +export function BonjourDeviceInputField({ value, setValue, connectionId, queryId }) { + const socket = useContext(SocketContext) + + const [_subId, setSubId] = useState(null) + const subIdRef = useRef(null) + + const [services, setServices] = useState({}) + + // Listen for data + useEffect(() => { + const onUp = (svc) => { + if (svc.subId !== subIdRef.current) return + + // console.log('up', svc) + + setServices((svcs) => { + return { + ...svcs, + [svc.fqdn]: svc, + } + }) + } + const onDown = (svc) => { + if (svc.subId !== subIdRef.current) return + + // console.log('down', svc) + + setServices((svcs) => { + const res = { ...svcs } + delete res[svc.fqdn] + return res + }) + } + + socket.on('bonjour:service:up', onUp) + socket.on('bonjour:service:down', onDown) + + return () => { + socket.off('bonjour:service:up', onUp) + socket.off('bonjour:service:down', onDown) + } + }, []) + + // Start/Stop the subscription + useEffect(() => { + let killed = false + let mySubId = null + socketEmitPromise(socket, 'bonjour:subscribe', [connectionId, queryId]) + .then((newSubId) => { + // Make sure it hasnt been terminated + if (killed) { + socket.emit('bonjour:unsubscribe', [newSubId]) + return + } + + // Sub is good, set it up + mySubId = newSubId + subIdRef.current = newSubId + setSubId(newSubId) + setServices({}) + }) + .catch((e) => { + console.error('Bonjour subscription failed: ', e) + }) + + return () => { + killed = true + + subIdRef.current = null + setSubId(null) + setServices({}) + + if (mySubId) socket.emit('bonjour:unsubscribe', mySubId) + } + }, [socket, connectionId, queryId]) + + const choicesRaw = useMemo(() => { + const choices = [] + + choices.push({ id: null, label: 'Manual' }) + + for (const svc of Object.values(services)) { + for (const rawAddress of svc.addresses || []) { + const address = `${rawAddress}:${svc.port}` + choices.push({ + id: address, + label: `${svc.name} (${address})`, + }) + } + } + + return choices + }, [services]) + + const choices = useMemo(() => { + const choices = [...choicesRaw] + + if (!choices.find((opt) => opt.id == value)) { + choices.push({ + id: value, + label: `*Unavailable* (${value})`, + }) + } + + return choices + }, [choicesRaw, value]) + + return +} diff --git a/webui/src/Instances/InstanceEditPanel.jsx b/webui/src/Instances/InstanceEditPanel.jsx index 7685b6e227..6028a2675a 100644 --- a/webui/src/Instances/InstanceEditPanel.jsx +++ b/webui/src/Instances/InstanceEditPanel.jsx @@ -8,6 +8,7 @@ import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' import sanitizeHtml from 'sanitize-html' import { isLabelValid } from '@companion/shared/Label' import CSwitch from '../CSwitch' +import { BonjourDeviceInputField } from '../Components/BonjourDeviceInputField' export function InstanceEditPanel({ instanceId, instanceStatus, doConfigureInstance, showHelp }) { console.log('status', instanceStatus) @@ -195,6 +196,7 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC valid={validFields[field.id]} setValue={setValue} setValid={setValid} + connectionId={instanceId} /> ) @@ -224,7 +226,7 @@ const InstanceEditPanelInner = memo(function InstanceEditPanel({ instanceId, doC ) }) -function ConfigField({ setValue, setValid, definition, value }) { +function ConfigField({ setValue, setValid, definition, value, connectionId }) { const id = definition.id const setValue2 = useCallback((val) => setValue(id, val), [setValue, id]) const setValid2 = useCallback((valid) => setValid(id, valid), [setValid, id]) @@ -319,6 +321,15 @@ function ConfigField({ setValue, setValid, definition, value }) { ) break } + case 'bonjour-device': + return ( + + ) default: return

Unknown field "{definition.type}"

} diff --git a/yarn.lock b/yarn.lock index e03835cdf5..56726bdd4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -645,6 +645,11 @@ simple-get "^4.0.1" string-split-by "^1.0.0" +"@leichtgewicht/ip-codec@^2.0.1": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" + integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== + "@loupedeck/core@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@loupedeck/core/-/core-1.0.0.tgz#d002c82b73e4f01315032c0e8e9c60f4c090bf20" @@ -1774,6 +1779,11 @@ array-flatten@1.1.1: resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== +array-flatten@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" + integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== + asn1@evs-broadcast/node-asn1: version "0.5.4" resolved "https://codeload.github.com/evs-broadcast/node-asn1/tar.gz/0146823069e479e90595480dc90c72cafa161ba1" @@ -1950,6 +1960,16 @@ body-parser@^1.20.2: type-is "~1.6.18" unpipe "1.0.0" +bonjour-service@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.1.1.tgz#960948fa0e0153f5d26743ab15baf8e33752c135" + integrity sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg== + dependencies: + array-flatten "^2.1.2" + dns-equal "^1.0.0" + fast-deep-equal "^3.1.3" + multicast-dns "^7.2.5" + bottleneck@^2.15.3: version "2.19.5" resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.19.5.tgz#5df0b90f59fd47656ebe63c78a98419205cadd91" @@ -2509,6 +2529,18 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dns-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" + integrity sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg== + +dns-packet@^5.2.2: + version "5.6.0" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.6.0.tgz#2202c947845c7a63c23ece58f2f70ff6ab4c2f7d" + integrity sha512-rza3UH1LwdHh9qyPXp8lkwpjSNk/AMD3dPytUoRoqnypDUhY0xvbdmVhWOfxO68frEfV9BU8V12Ez7ZsHGZpCQ== + dependencies: + "@leichtgewicht/ip-codec" "^2.0.1" + dotenv@^16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" @@ -2812,7 +2844,7 @@ express@^4.18.2: utils-merge "1.0.1" vary "~1.1.2" -fast-deep-equal@^3.1.1: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== @@ -4344,6 +4376,14 @@ ms@2.1.3, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +multicast-dns@^7.2.5: + version "7.2.5" + resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced" + integrity sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg== + dependencies: + dns-packet "^5.2.2" + thunky "^1.0.2" + nanoid@^3.3.4: version "3.3.6" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" @@ -5495,6 +5535,11 @@ through@2, through@~2.3, through@~2.3.1: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== +thunky@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" + integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"