From 47cc57445e25481d20e713d6973a5a5b64422e5d Mon Sep 17 00:00:00 2001 From: hensm Date: Sun, 1 Mar 2026 19:08:29 +0000 Subject: [PATCH] wip: Replace unmaintained mdns module with a custom native module --- .gitignore | 2 +- .vscode/c_cpp_properties.json | 23 ++ .vscode/settings.json | 9 +- bridge/bin/build.js | 43 ++- bridge/binding.gyp | 60 +++ bridge/package-lock.json | 104 +++-- bridge/package.json | 5 +- bridge/packaging/win/installer.nsi | 23 -- bridge/src/bridge/components/cast/Session.ts | 3 +- bridge/src/bridge/components/cast/client.ts | 14 +- .../cast/{discovery.ts => deviceBrowser.ts} | 53 ++- bridge/src/bridge/components/cast/remote.ts | 6 +- bridge/src/bridge/index.ts | 120 +++--- bridge/src/dns_sd/.clang-format | 3 + bridge/src/dns_sd/index.ts | 68 ++++ bridge/src/dns_sd/native/addon.cc | 10 + bridge/src/dns_sd/native/dns_sd_browser.cc | 119 ++++++ bridge/src/dns_sd/native/dns_sd_browser.h | 30 ++ .../dns_sd/native/dns_sd_platform_browser.h | 49 +++ .../native/dns_sd_platform_browser_unix.cc | 262 +++++++++++++ .../native/dns_sd_platform_browser_win.cc | 362 ++++++++++++++++++ bridge/src/dns_sd/native/utils.h | 55 +++ 22 files changed, 1231 insertions(+), 192 deletions(-) create mode 100644 .vscode/c_cpp_properties.json create mode 100644 bridge/binding.gyp rename bridge/src/bridge/components/cast/{discovery.ts => deviceBrowser.ts} (52%) create mode 100644 bridge/src/dns_sd/.clang-format create mode 100644 bridge/src/dns_sd/index.ts create mode 100644 bridge/src/dns_sd/native/addon.cc create mode 100644 bridge/src/dns_sd/native/dns_sd_browser.cc create mode 100644 bridge/src/dns_sd/native/dns_sd_browser.h create mode 100644 bridge/src/dns_sd/native/dns_sd_platform_browser.h create mode 100644 bridge/src/dns_sd/native/dns_sd_platform_browser_unix.cc create mode 100644 bridge/src/dns_sd/native/dns_sd_platform_browser_win.cc create mode 100644 bridge/src/dns_sd/native/utils.h diff --git a/.gitignore b/.gitignore index 3c03aef..963fa96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ node_modules/ dist/ bridge/node_modules/ -bridge/build +bridge/build/ extension/node_modules/ test/ChromeProfile/ .idea/ diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json new file mode 100644 index 0000000..9825b93 --- /dev/null +++ b/.vscode/c_cpp_properties.json @@ -0,0 +1,23 @@ +{ + "configurations": [ + { + "name": "Win32", + "includePath": [ + "${workspaceFolder}/**", + "${env:USERPROFILE}/AppData/Local/node-gyp/Cache/**/include/node", + "${workspaceFolder}/bridge/node_modules/node-addon-api" + ], + "defines": [ + "_DEBUG", + "UNICODE", + "_UNICODE" + ], + "windowsSdkVersion": "10.0.22621.0", + "compilerPath": "cl.exe", + "cStandard": "c17", + "cppStandard": "c++20", + "intelliSenseMode": "windows-msvc-x64" + } + ], + "version": 4 +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index db01ee6..63296cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,10 @@ { - "eslint.validate": ["javascript", "svelte"] + "eslint.validate": ["javascript", "svelte"], + "search.exclude": { + "**/node_modules": true, + "**/bower_components": true, + "**/*.code-search": true, + "**/dist": true, + "**/build": true + }, } diff --git a/bridge/bin/build.js b/bridge/bin/build.js index 88dd9ed..362c0e1 100644 --- a/bridge/bin/build.js +++ b/bridge/bin/build.js @@ -58,7 +58,7 @@ if (!supportedTargets[process.platform]?.includes(argv.arch)) { const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); const ROOT_PATH = path.join(__dirname, ".."); -const BUILD_PATH = path.join(ROOT_PATH, "build"); +const BUILD_PATH = path.join(ROOT_PATH, "dist/app"); const spawnOptions = { shell: true, @@ -70,15 +70,14 @@ const spawnOptions = { * build directories, just in case. */ fs.rmSync(BUILD_PATH, { force: true, recursive: true }); -fs.rmSync(paths.DIST_PATH, { force: true, recursive: true }); fs.mkdirSync(BUILD_PATH, { recursive: true }); -fs.mkdirSync(paths.DIST_PATH, { recursive: true }); +if (argv.package) { + fs.rmSync(paths.DIST_PATH, { force: true, recursive: true }); + fs.mkdirSync(paths.DIST_PATH, { recursive: true }); +} -const MDNS_BINDING_PATH = path.join( - __dirname, - "../node_modules/mdns/build/Release/" -); -const MDNS_BINDING_NAME = "dns_sd_bindings.node"; +const NATIVE_BINDING_PATH = path.join(ROOT_PATH, "build/Release"); +const NATIVE_BINDING_NAME = "dns_sd.node"; async function build() { // Run tsc @@ -140,8 +139,8 @@ async function build() { ]); fs.copySync( - path.join(MDNS_BINDING_PATH, MDNS_BINDING_NAME), - path.join(BUILD_PATH, MDNS_BINDING_NAME) + path.join(NATIVE_BINDING_PATH, NATIVE_BINDING_NAME), + path.join(BUILD_PATH, NATIVE_BINDING_NAME) ); fs.rmSync(path.join(BUILD_PATH, "src"), { @@ -190,8 +189,20 @@ NODE_PATH="${modulesDir}" node $(dirname $0)/src/main.js --__name $(basename $0) } manifest.path = path.join(paths.DIST_PATH, path.basename(launcherPath)); + + // Copy native binding into build/Release so bindings() finds it + fs.copySync( + path.join(NATIVE_BINDING_PATH, NATIVE_BINDING_NAME), + path.join(BUILD_PATH, "build", "Release", NATIVE_BINDING_NAME) + ); } + // Write a package.json so the bindings module resolves from this directory + fs.writeFileSync( + path.join(BUILD_PATH, "package.json"), + "{}" + ); + // Write app manifest fs.writeFileSync( path.join(BUILD_PATH, paths.MANIFEST_NAME), @@ -318,8 +329,8 @@ function packageDarwin( path.join(rootExecutableDirectory, platformExecutableName) ); fs.moveSync( - path.join(BUILD_PATH, MDNS_BINDING_NAME), - path.join(rootExecutableDirectory, MDNS_BINDING_NAME) + path.join(BUILD_PATH, NATIVE_BINDING_NAME), + path.join(rootExecutableDirectory, NATIVE_BINDING_NAME) ); fs.moveSync( path.join(BUILD_PATH, paths.MANIFEST_NAME), @@ -416,8 +427,8 @@ function packageLinuxDeb( path.join(rootExecutableDirectory, platformExecutableName) ); fs.moveSync( - path.join(BUILD_PATH, MDNS_BINDING_NAME), - path.join(rootExecutableDirectory, MDNS_BINDING_NAME) + path.join(BUILD_PATH, NATIVE_BINDING_NAME), + path.join(rootExecutableDirectory, NATIVE_BINDING_NAME) ); fs.moveSync( path.join(BUILD_PATH, paths.MANIFEST_NAME), @@ -490,7 +501,7 @@ function packageLinuxRpm( manifestPath: platformManifestDirectory, executableName: platformExecutableName, manifestName: paths.MANIFEST_NAME, - bindingName: MDNS_BINDING_NAME + bindingName: NATIVE_BINDING_NAME }; fs.writeFileSync( @@ -539,7 +550,7 @@ function packageWin32( executableName: platformExecutableName, executablePath: platformExecutableDirectory, manifestName: paths.MANIFEST_NAME, - bindingName: MDNS_BINDING_NAME, + bindingName: NATIVE_BINDING_NAME, winRegistryKey: paths.REGISTRY_KEY, outputName, licensePath: paths.LICENSE_PATH, diff --git a/bridge/binding.gyp b/bridge/binding.gyp new file mode 100644 index 0000000..7933a77 --- /dev/null +++ b/bridge/binding.gyp @@ -0,0 +1,60 @@ +{ + "targets": [ + { + "target_name": "dns_sd", + "cflags!": ["-fno-exceptions"], + "cflags_cc!": ["-fno-exceptions"], + "defines": ["NAPI_VERSION=8", "NAPI_DISABLE_CPP_EXCEPTIONS"], + "include_dirs": ["= 14.13" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -1115,16 +1125,6 @@ "node": ">=10" } }, - "node_modules/mdns": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/mdns/-/mdns-2.7.2.tgz", - "integrity": "sha512-NBOQT22DKvuNWVY7nKNbs6w9eGRyPwnc4ZjKOsCG2G/4wNt1+IyiHvc+5yhcAUZLG46cOY321YW7Ufz3lMtrhw==", - "hasInstallScript": true, - "dependencies": { - "bindings": "~1.2.1", - "nan": "^2.14.0" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1242,11 +1242,6 @@ "mustache": "bin/mustache" } }, - "node_modules/nan": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==" - }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -1271,6 +1266,15 @@ "semver": "bin/semver" } }, + "node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -2390,15 +2394,6 @@ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" }, - "@types/mdns": { - "version": "0.0.34", - "resolved": "https://registry.npmjs.org/@types/mdns/-/mdns-0.0.34.tgz", - "integrity": "sha512-4Rrt/0wRAudtOnmhfDdoFhy5r20yHe0KiDK+/+I9RBBMW67F4S6y8tJH06AzrUDZzS/SH/U2pw1W0lrgQ+OlPg==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/mime-types": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", @@ -2522,9 +2517,12 @@ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==" }, "bindings": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz", - "integrity": "sha1-FK1hE4EtLTfXLme0ystLtyZQXxE=" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "requires": { + "file-uri-to-path": "1.0.0" + } }, "bl": { "version": "4.1.0", @@ -2848,6 +2846,11 @@ "web-streams-polyfill": "^3.0.3" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3108,15 +3111,6 @@ "yallist": "^4.0.0" } }, - "mdns": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/mdns/-/mdns-2.7.2.tgz", - "integrity": "sha512-NBOQT22DKvuNWVY7nKNbs6w9eGRyPwnc4ZjKOsCG2G/4wNt1+IyiHvc+5yhcAUZLG46cOY321YW7Ufz3lMtrhw==", - "requires": { - "bindings": "~1.2.1", - "nan": "^2.14.0" - } - }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3198,11 +3192,6 @@ "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", "dev": true }, - "nan": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==" - }, "napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -3226,6 +3215,11 @@ } } }, + "node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==" + }, "node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", diff --git a/bridge/package.json b/bridge/package.json index 77ae6d1..c222c6f 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -2,25 +2,26 @@ "type": "module", "scripts": { "build": "node bin/build.js", + "install": "node-gyp rebuild", "package": "node bin/build.js --package", "install-manifest": "node bin/install-manifest.js", "remove-manifest": "node bin/install-manifest.js --remove" }, "dependencies": { + "bindings": "^1.5.0", "bplist-creator": "^0.1.0", "bplist-parser": "^0.3.1", "castv2": "^0.1.10", "chalk": "^4.1.2", "fast-srp-hap": "^2.0.4", - "mdns": "^2.7.2", "mime-types": "^2.1.35", + "node-addon-api": "^8.0.0", "node-fetch": "^3.2.10", "tweetnacl": "^1.0.3", "ws": "^8.5.0", "yargs": "^17.5.1" }, "devDependencies": { - "@types/mdns": "^0.0.34", "@types/mime-types": "^2.1.1", "@types/minimist": "^1.2.2", "@types/node": "^22.0.0", diff --git a/bridge/packaging/win/installer.nsi b/bridge/packaging/win/installer.nsi index fab691c..e2bae1e 100644 --- a/bridge/packaging/win/installer.nsi +++ b/bridge/packaging/win/installer.nsi @@ -35,16 +35,12 @@ SetCompressor /SOLID LZMA !insertmacro MUI_LANGUAGE "German" # lang:en -LangString MSG__INSTALL_BONJOUR ${LANG_ENGLISH} \ - "Install Bonjour dependency?" LangString MSG__FIREFOX_OPEN ${LANG_ENGLISH} \ "Firefox must be closed during uninstallation if the extension is \ installed. Close Firefox and click $\"Retry$\", click $\"Ignore$\" \ to force close or $\"Abort$\" to cancel uninstallation." # lang:es -LangString MSG__INSTALL_BONJOUR ${LANG_SPANISH} \ - "¿Instalar dependencia Bonjour?" LangString MSG__FIREFOX_OPEN ${LANG_SPANISH} \ "Firefox debe estar cerrado durante la desinstalación si la extensión \ está instalada. Cierra Firefox y aprieta $\"Reintentar$\", aprieta \ @@ -52,8 +48,6 @@ LangString MSG__FIREFOX_OPEN ${LANG_SPANISH} \ desinstalación." # lang:de -LangString MSG__INSTALL_BONJOUR ${LANG_GERMAN} \ - "Bonjour installieren?" LangString MSG__FIREFOX_OPEN ${LANG_GERMAN} \ "Firefox muss während der Deinstallation geschlossen werden, wenn die \ Erweiterung installiert ist. Schließen Sie Firefox und klicken Sie auf \ @@ -86,23 +80,6 @@ Section File "{{bindingName}}" File "{{manifestName}}" - # Install Bonjour - IfFileExists "$SYSDIR\dnssd.dll" skipInstallBonjour - MessageBox MB_YESNO \ - $(MSG__INSTALL_BONJOUR) \ - IDNO skipInstallBonjour - - ${If} ${ARCH} == "x86" - File /oname=Bonjour.msi "C:\Program Files\Bonjour SDK\Installer\Bonjour.msi" - ${ElseIf} ${ARCH} == "x64" - File /oname=Bonjour.msi "C:\Program Files\Bonjour SDK\Installer\Bonjour64.msi" - ${EndIf} - - ExecWait "msiexec /i $\"$INSTDIR\Bonjour.msi$\"" - - skipInstallBonjour: - Delete "$INSTDIR\Bonjour.msi" - # Native manifest key WriteRegStr HKLM "${KEY_MANIFEST}" "" "$INSTDIR\{{manifestName}}" diff --git a/bridge/src/bridge/components/cast/Session.ts b/bridge/src/bridge/components/cast/Session.ts index fc599e1..f13d5a8 100644 --- a/bridge/src/bridge/components/cast/Session.ts +++ b/bridge/src/bridge/components/cast/Session.ts @@ -180,7 +180,8 @@ export default class Session extends CastClient { type: "LAUNCH", appId: this.appId }); - }); + }) + .catch(() => {}); // Handle client connection closed this.client.on("close", () => { diff --git a/bridge/src/bridge/components/cast/client.ts b/bridge/src/bridge/components/cast/client.ts index 2c90a6f..913b8f1 100644 --- a/bridge/src/bridge/components/cast/client.ts +++ b/bridge/src/bridge/components/cast/client.ts @@ -66,8 +66,19 @@ export default class CastClient { */ connect(host: string, options?: CastClientConnectOptions) { return new Promise((resolve, reject) => { + let connected = false; + // Handle errors - this.client.on("error", reject); + this.client.on("error", err => { + if (!connected) { + reject(err); + } else { + try { + this.client.close(); + } catch {} + } + }); + this.client.on("close", () => { if (this.heartbeatChannel && this.heartbeatIntervalId) { clearInterval(this.heartbeatIntervalId); @@ -84,6 +95,7 @@ export default class CastClient { }, // On connection callback () => { + connected = true; this.connectionChannel = this.createChannel(NS_CONNECTION); this.heartbeatChannel = this.createChannel(NS_HEARTBEAT); diff --git a/bridge/src/bridge/components/cast/discovery.ts b/bridge/src/bridge/components/cast/deviceBrowser.ts similarity index 52% rename from bridge/src/bridge/components/cast/discovery.ts rename to bridge/src/bridge/components/cast/deviceBrowser.ts index b89a481..844b1b6 100644 --- a/bridge/src/bridge/components/cast/discovery.ts +++ b/bridge/src/bridge/components/cast/deviceBrowser.ts @@ -1,9 +1,9 @@ -import mdns from "mdns"; - +import { EventEmitter } from "events"; +import { DnsSdBrowser } from "../../../dns_sd"; import type { ReceiverDevice } from "../../messagingTypes"; /** - * Chromecast TXT record + * Chromecast TXT record fields. */ interface CastRecord { // Device ID @@ -27,54 +27,47 @@ interface CastRecord { rs: string; } -interface DiscoveryOptions { - onDeviceFound(device: ReceiverDevice): void; - onDeviceDown(deviceId: string): void; -} +export default class CastDeviceBrowser extends EventEmitter<{ + deviceUp: [device: ReceiverDevice]; + deviceDown: [deviceId: string]; +}> { + browser = new DnsSdBrowser("_googlecast._tcp"); -export default class Discovery { - browser = mdns.createBrowser(mdns.tcp("googlecast"), { - resolverSequence: [ - mdns.rst.DNSServiceResolve(), - "DNSServiceGetAddrInfo" in mdns.dns_sd - ? mdns.rst.DNSServiceGetAddrInfo() - : // Some issues on Linux with IPv6, so restrict to IPv4 - mdns.rst.getaddrinfo({ families: [4] }), - mdns.rst.makeAddressesUnique() - ] - }); - - constructor(opts: DiscoveryOptions) { + constructor() { + super(); /** - * When a service is found, gather device info from service - * object and TXT record, then send a `main:deviceUp` message. + * When a service is found, gather device info from service object and + * TXT record, then send a `main:deviceUp` message. */ this.browser.on("serviceUp", service => { // Filter invalid results if (!service.txtRecord || !service.name) return; - const record = service.txtRecord as CastRecord; + const address = service.address4 ?? service.address6; + if (!address) return; + + const record = service.txtRecord as unknown as CastRecord; const device: ReceiverDevice = { id: record.id, friendlyName: record.fn, modelName: record.md, capabilities: parseInt(record.ca), - host: service.addresses[0], + host: address, port: service.port }; - opts.onDeviceFound(device); + this.emit("deviceUp", device); }); /** - * When a service is lost, send a `main:deviceDown` message with - * the service name as the `deviceId`. + * When a service is lost, send a `main:deviceDown` message with the + * service name as the `deviceId`. */ - this.browser.on("serviceDown", service => { + this.browser.on("serviceDown", name => { // Filter invalid results - if (!service.name) return; + if (!name) return; - opts.onDeviceDown(service.name); + this.emit("deviceDown", name); }); } diff --git a/bridge/src/bridge/components/cast/remote.ts b/bridge/src/bridge/components/cast/remote.ts index 86713f3..37fbb21 100644 --- a/bridge/src/bridge/components/cast/remote.ts +++ b/bridge/src/bridge/components/cast/remote.ts @@ -35,7 +35,8 @@ export default class Remote extends CastClient { }) .then(() => { this.sendReceiverMessage({ type: "GET_STATUS" }); - }); + }) + .catch(() => {}); } disconnect() { @@ -85,7 +86,8 @@ export default class Remote extends CastClient { type: "GET_STATUS", requestId: 0 }); - }); + }) + .catch(() => {}); this.options?.onApplicationFound?.(); } diff --git a/bridge/src/bridge/index.ts b/bridge/src/bridge/index.ts index 7cac7cd..bfd014d 100755 --- a/bridge/src/bridge/index.ts +++ b/bridge/src/bridge/index.ts @@ -1,7 +1,7 @@ import type { Messenger, Message } from "./messaging"; import { handleCastMessage } from "./components/cast"; -import Discovery from "./components/cast/discovery"; +import CastDeviceBrowser from "./components/cast/deviceBrowser"; import Remote from "./components/cast/remote"; import { startMediaServer, stopMediaServer } from "./components/mediaServer"; @@ -9,7 +9,7 @@ import { startMediaServer, stopMediaServer } from "./components/mediaServer"; import { applicationVersion } from "../../config.json"; process.on("SIGTERM", async () => { - discovery?.stop(); + deviceBrowser?.stop(); try { await stopMediaServer(); } catch (err) { @@ -19,15 +19,15 @@ process.on("SIGTERM", async () => { } }); -let discovery: Discovery | null = null; +let deviceBrowser: CastDeviceBrowser | null = null; const remotes = new Map(); /** - * Handle incoming messages from the extension and forward - * them to the appropriate handlers. + * Handle incoming messages from the extension and forward them to the + * appropriate handlers. * - * Initializes the counterpart objects and is responsible - * for managing existing ones. + * Initializes the counterpart objects and is responsible for managing existing + * ones. */ export function run(messaging: Messenger) { messaging.on("message", (message: Message) => { @@ -41,66 +41,66 @@ export function run(messaging: Messenger) { case "bridge:startDiscovery": { const { shouldWatchStatus } = message.data; - discovery = new Discovery({ - onDeviceFound(device) { - messaging.sendMessage({ - subject: "main:deviceUp", - data: { - deviceId: device.id, - deviceInfo: device - } - }); + deviceBrowser = new CastDeviceBrowser(); - if (shouldWatchStatus) { - remotes.set( - device.id, - new Remote(device.host, { - port: device.port, - // RECEIVER_STATUS - onReceiverStatusUpdate(status) { - messaging.sendMessage({ - subject: - "main:receiverDeviceStatusUpdated", - data: { - deviceId: device.id, - status - } - }); - }, - // MEDIA_STATUS - onMediaStatusUpdate(status) { - if (!status) return; - - messaging.sendMessage({ - subject: - "main:receiverDeviceMediaStatusUpdated", - data: { - deviceId: device.id, - status - } - }); - } - }) - ); + deviceBrowser.on("deviceUp", device => { + messaging.sendMessage({ + subject: "main:deviceUp", + data: { + deviceId: device.id, + deviceInfo: device } - }, - onDeviceDown(deviceId) { - messaging.sendMessage({ - subject: "main:deviceDown", - data: { deviceId } - }); + }); - if (shouldWatchStatus) { - if (remotes.has(deviceId)) { - remotes.get(deviceId)?.disconnect(); - remotes.delete(deviceId); - } + if (shouldWatchStatus) { + remotes.set( + device.id, + new Remote(device.host, { + port: device.port, + // RECEIVER_STATUS + onReceiverStatusUpdate(status) { + messaging.sendMessage({ + subject: + "main:receiverDeviceStatusUpdated", + data: { + deviceId: device.id, + status + } + }); + }, + // MEDIA_STATUS + onMediaStatusUpdate(status) { + if (!status) return; + + messaging.sendMessage({ + subject: + "main:receiverDeviceMediaStatusUpdated", + data: { + deviceId: device.id, + status + } + }); + } + }) + ); + } + }); + + deviceBrowser.on("deviceDown", deviceId => { + messaging.sendMessage({ + subject: "main:deviceDown", + data: { deviceId } + }); + + if (shouldWatchStatus) { + if (remotes.has(deviceId)) { + remotes.get(deviceId)?.disconnect(); + remotes.delete(deviceId); } } }); - discovery.start(); - + deviceBrowser.start(); break; } diff --git a/bridge/src/dns_sd/.clang-format b/bridge/src/dns_sd/.clang-format new file mode 100644 index 0000000..aef0280 --- /dev/null +++ b/bridge/src/dns_sd/.clang-format @@ -0,0 +1,3 @@ +BasedOnStyle: Webkit +ColumnLimit: 100 +SortIncludes: false diff --git a/bridge/src/dns_sd/index.ts b/bridge/src/dns_sd/index.ts new file mode 100644 index 0000000..7afb4cc --- /dev/null +++ b/bridge/src/dns_sd/index.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from "events"; + +const native = require("bindings")("dns_sd"); + +export interface Service { + /** Service instance name */ + name: string; + /** Resolved hostname */ + host: string; + /** Service port */ + port: number; + /** Resolved IPv4 address */ + address4?: string; + /** Resolved IPv6 address */ + address6?: string; + /** DNS TXT record key-value pairs */ + txtRecord: Record; +} + +interface NativeDnsSdBrowser { + start(): void; + stop(): void; +} +const NativeDnsSdBrowser = native.DnsSdBrowser as { + new ( + serviceType: string, + callback: (eventType: string, data: Service | string) => void + ): NativeDnsSdBrowser; +}; + +export interface DnsSdBrowserEvents { + serviceUp: [service: Service]; + serviceDown: [name: string]; +} + +export class DnsSdBrowser extends EventEmitter { + private nativeBrowser: NativeDnsSdBrowser | null = null; + + constructor(private serviceType: string) { + super(); + } + + public start(): void { + if (!this.nativeBrowser) { + this.nativeBrowser = new NativeDnsSdBrowser( + this.serviceType, + (eventType, data) => { + switch (eventType) { + case "serviceUp": + this.emit("serviceUp", data as Service); + break; + case "serviceDown": + this.emit("serviceDown", data as string); + break; + } + } + ); + this.nativeBrowser.start(); + } + } + + public stop(): void { + if (this.nativeBrowser) { + this.nativeBrowser.stop(); + this.nativeBrowser = null; + } + } +} diff --git a/bridge/src/dns_sd/native/addon.cc b/bridge/src/dns_sd/native/addon.cc new file mode 100644 index 0000000..9481a42 --- /dev/null +++ b/bridge/src/dns_sd/native/addon.cc @@ -0,0 +1,10 @@ +#include "dns_sd_browser.h" + +// Module init +Napi::Object init(Napi::Env env, Napi::Object exports) +{ + DnsSdBrowser::init(env, exports); + return exports; +} + +NODE_API_MODULE(dns_sd, init) diff --git a/bridge/src/dns_sd/native/dns_sd_browser.cc b/bridge/src/dns_sd/native/dns_sd_browser.cc new file mode 100644 index 0000000..07c23fb --- /dev/null +++ b/bridge/src/dns_sd/native/dns_sd_browser.cc @@ -0,0 +1,119 @@ +#include "dns_sd_browser.h" + +DnsSdBrowser::DnsSdBrowser(const Napi::CallbackInfo& info) + : Napi::ObjectWrap(info) + , browser_(nullptr) + , started_(false) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) { + Napi::TypeError::New(env, "Expected (serviceType: string, callback: Function)") + .ThrowAsJavaScriptException(); + return; + } + service_type_ = info[0].As().Utf8Value(); + tsfn_ = Napi::ThreadSafeFunction::New( + env, info[1].As(), "DnsSdBrowserCallback", 0, 1); +} + +DnsSdBrowser::~DnsSdBrowser() +{ + if (browser_) { + browser_->stop(); + browser_.reset(); + } + if (started_) { + tsfn_.Release(); + started_ = false; + } +} + +Napi::Object DnsSdBrowser::init(Napi::Env env, Napi::Object exports) +{ + Napi::Function func = DefineClass(env, "DnsSdBrowser", + { + InstanceMethod("start", &DnsSdBrowser::start), + InstanceMethod("stop", &DnsSdBrowser::stop), + }); + + exports.Set("DnsSdBrowser", func); + return exports; +} + +Napi::Value DnsSdBrowser::start(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + + if (started_) { + return env.Undefined(); + } + started_ = true; + + tsfn_.Unref(env); + + browser_ = std::make_unique(service_type_, *this); + browser_->start(); + return env.Undefined(); +} + +Napi::Value DnsSdBrowser::stop(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + + if (!started_) { + return env.Undefined(); + } + + if (browser_) { + browser_->stop(); + browser_.reset(); + } + + tsfn_.Release(); + started_ = false; + + return env.Undefined(); +} + +void DnsSdBrowser::on_service_up(const DnsSdService& service) +{ + auto data = std::make_unique(service); + + napi_status status = tsfn_.NonBlockingCall( + data.get(), [](Napi::Env env, Napi::Function js_callback, DnsSdService* raw) { + std::unique_ptr owned(raw); + + Napi::Object obj = Napi::Object::New(env); + obj.Set("name", Napi::String::New(env, owned->name)); + obj.Set("host", Napi::String::New(env, owned->host)); + obj.Set("port", Napi::Number::New(env, owned->port)); + if (!owned->address4.empty()) + obj.Set("address4", Napi::String::New(env, owned->address4)); + if (!owned->address6.empty()) + obj.Set("address6", Napi::String::New(env, owned->address6)); + + Napi::Object txt = Napi::Object::New(env); + for (const auto& [key, value] : owned->txt_record) { + txt.Set(key, Napi::String::New(env, value)); + } + obj.Set("txtRecord", txt); + + js_callback.Call({ Napi::String::New(env, "serviceUp"), obj }); + }); + if (status == napi_ok) + data.release(); +} + +void DnsSdBrowser::on_service_down(const std::string& name) +{ + auto data = std::make_unique(name); + + napi_status status = tsfn_.NonBlockingCall( + data.get(), [](Napi::Env env, Napi::Function js_callback, std::string* raw) { + std::unique_ptr owned(raw); + js_callback.Call( + { Napi::String::New(env, "serviceDown"), Napi::String::New(env, *owned) }); + }); + if (status == napi_ok) + data.release(); +} diff --git a/bridge/src/dns_sd/native/dns_sd_browser.h b/bridge/src/dns_sd/native/dns_sd_browser.h new file mode 100644 index 0000000..dd2d247 --- /dev/null +++ b/bridge/src/dns_sd/native/dns_sd_browser.h @@ -0,0 +1,30 @@ +#ifndef DNS_SD_BROWSER_H +#define DNS_SD_BROWSER_H + +#include "dns_sd_platform_browser.h" + +#include +#include +#include + +class DnsSdBrowser : public Napi::ObjectWrap, public DnsSdPlatformBrowserDelegate { +public: + static Napi::Object init(Napi::Env env, Napi::Object exports); + DnsSdBrowser(const Napi::CallbackInfo& info); + ~DnsSdBrowser(); + + // DnsSdPlatformBrowserDelegate + void on_service_up(const DnsSdService& service) override; + void on_service_down(const std::string& name) override; + +private: + Napi::Value start(const Napi::CallbackInfo& info); + Napi::Value stop(const Napi::CallbackInfo& info); + + std::string service_type_; + Napi::ThreadSafeFunction tsfn_; + std::unique_ptr browser_; + bool started_; +}; + +#endif // DNS_SD_BROWSER_H diff --git a/bridge/src/dns_sd/native/dns_sd_platform_browser.h b/bridge/src/dns_sd/native/dns_sd_platform_browser.h new file mode 100644 index 0000000..c010a08 --- /dev/null +++ b/bridge/src/dns_sd/native/dns_sd_platform_browser.h @@ -0,0 +1,49 @@ +#ifndef DNS_SD_PLATFORM_BROWSER_H +#define DNS_SD_PLATFORM_BROWSER_H + +#include +#include +#include +#include + +/** + * Represents a resolved DNS-SD service. + */ +struct DnsSdService { + std::string name; + std::string host; + uint16_t port = 0; + std::string address4; + std::string address6; + std::map txt_record; +}; + +/** + * Delegate interface for receiving DNS-SD browser events. + */ +class DnsSdPlatformBrowserDelegate { +public: + virtual ~DnsSdPlatformBrowserDelegate() = default; + virtual void on_service_up(const DnsSdService& service) = 0; + virtual void on_service_down(const std::string& name) = 0; +}; + +/** + * Platform-specific DNS-SD browser. + * Implemented in dns_sd_platform_browser_unix.cc (macOS/Linux) and + * dns_sd_platform_browser_win.cc (Windows). + */ +class DnsSdPlatformBrowser { +public: + DnsSdPlatformBrowser(const std::string& service_type, DnsSdPlatformBrowserDelegate& delegate); + ~DnsSdPlatformBrowser(); + + void start(); + void stop(); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +#endif // DNS_SD_PLATFORM_BROWSER_H diff --git a/bridge/src/dns_sd/native/dns_sd_platform_browser_unix.cc b/bridge/src/dns_sd/native/dns_sd_platform_browser_unix.cc new file mode 100644 index 0000000..2159201 --- /dev/null +++ b/bridge/src/dns_sd/native/dns_sd_platform_browser_unix.cc @@ -0,0 +1,262 @@ +/** + * DNS-SD browser implementation for macOS and Linux. + * + * Uses the dns_sd.h API (Apple's mDNSResponder on macOS and Avahi's compatibility layer for that + * API (libdns_sd) on Linux) to browse for and resolve DNS-SD services. + * + * Address resolution uses POSIX getaddrinfo() on both platforms rather than DNSServiceGetAddrInfo, + * which is not available in libdns_sd. + */ + +#include "dns_sd_platform_browser.h" +#include "utils.h" + +#include + +#include +#include +#include +#include + +struct DnsSdPlatformBrowser::Impl { + struct ResolveContext; + + std::string service_type; + DnsSdPlatformBrowserDelegate& delegate; + + DNSServiceRef browse_ref; + std::atomic is_started; + std::thread event_loop_thread; + + std::mutex pending_resolves_mutex; + std::set pending_resolves; + + Impl(const std::string& service_type, DnsSdPlatformBrowserDelegate& delegate) + : service_type(service_type) + , delegate(delegate) + , browse_ref(nullptr) + , is_started(false) + { + } + + ~Impl() { stop(); } + + void start(); + void stop(); + void event_loop(); + + // dns_sd callbacks + static void DNSSD_API browse_callback(DNSServiceRef, DNSServiceFlags, uint32_t, + DNSServiceErrorType, const char*, const char*, const char*, void*); + + static void DNSSD_API resolve_callback(DNSServiceRef, DNSServiceFlags, uint32_t, + DNSServiceErrorType, const char*, const char*, uint16_t, uint16_t, const unsigned char*, + void*); +}; + +struct DnsSdPlatformBrowser::Impl::ResolveContext { + Impl* impl; + std::string service_name; + DNSServiceRef resolve_ref; + bool destroyed; + + ResolveContext() + : impl(nullptr) + , resolve_ref(nullptr) + , destroyed(false) + { + } + ~ResolveContext() + { + if (resolve_ref) + DNSServiceRefDeallocate(resolve_ref); + } +}; + +DnsSdPlatformBrowser::DnsSdPlatformBrowser( + const std::string& service_type, DnsSdPlatformBrowserDelegate& delegate) + : impl_(std::make_unique(service_type, delegate)) +{ +} + +DnsSdPlatformBrowser::~DnsSdPlatformBrowser() = default; + +void DnsSdPlatformBrowser::start() { impl_->start(); } + +void DnsSdPlatformBrowser::stop() { impl_->stop(); } + +void DnsSdPlatformBrowser::Impl::start() +{ + if (is_started) + return; + is_started = true; + + DNSServiceErrorType err = DNSServiceBrowse(&browse_ref, 0, kDNSServiceInterfaceIndexAny, + service_type.c_str(), nullptr, browse_callback, this); + + if (err != kDNSServiceErr_NoError) { + ERROR_LOG("browse failed with error %d", err); + is_started = false; + return; + } + + DEBUG_LOG("browse started for %s", service_type.c_str()); + // Poll on background thread + event_loop_thread = std::thread(&Impl::event_loop, this); +} + +void DnsSdPlatformBrowser::Impl::stop() +{ + if (!is_started) + return; + is_started = false; + + if (event_loop_thread.joinable()) { + event_loop_thread.join(); + } + + // Clean up pending resolves + { + std::scoped_lock lock(pending_resolves_mutex); + for (auto* ctx : pending_resolves) { + delete ctx; + } + pending_resolves.clear(); + } + + DNSServiceRefDeallocate(browse_ref); +} + +/** + * Background thread that waits for DNS-SD socket events. When data is available, calls + * DNSServiceProcessResult to trigger the registered callbacks. + */ +void DnsSdPlatformBrowser::Impl::event_loop() +{ + while (is_started) { + int max_fd = 0; + fd_set read_fds; + FD_ZERO(&read_fds); + + // Add the browse socket + int browse_fd = DNSServiceRefSockFD(browse_ref); + FD_SET(browse_fd, &read_fds); + max_fd = browse_fd; + + // Add resolve sockets + { + std::scoped_lock lock(pending_resolves_mutex); + for (auto* ctx : pending_resolves) { + int fd = DNSServiceRefSockFD(ctx->resolve_ref); + FD_SET(fd, &read_fds); + if (fd > max_fd) + max_fd = fd; + } + } + + struct timeval tv { + .tv_sec = 0, .tv_usec = 250000 + }; // 250ms + int result = select(max_fd + 1, &read_fds, nullptr, nullptr, &tv); + + if (result <= 0 || !is_started) + continue; + + // Process browse ref + if (FD_ISSET(browse_fd, &read_fds)) { + DNSServiceProcessResult(browse_ref); + } + + // Process resolve refs + { + std::scoped_lock lock(pending_resolves_mutex); + for (auto it = pending_resolves.begin(); it != pending_resolves.end();) { + auto* ctx = *it; + int fd = DNSServiceRefSockFD(ctx->resolve_ref); + if (FD_ISSET(fd, &read_fds)) { + DNSServiceProcessResult(ctx->resolve_ref); + } + if (ctx->destroyed) { + it = pending_resolves.erase(it); + delete ctx; + } else { + ++it; + } + } + } + } +} + +void DNSSD_API DnsSdPlatformBrowser::Impl::browse_callback(DNSServiceRef, DNSServiceFlags flags, + uint32_t interface_index, DNSServiceErrorType error_code, const char* service_name, + const char* reg_type, const char* reply_domain, void* context) +{ + if (error_code != kDNSServiceErr_NoError) + return; + + auto* impl = static_cast(context); + if (!impl->is_started) + return; + + if (flags & kDNSServiceFlagsAdd) { + DEBUG_LOG("browse found: %s (ifindex=%u)", service_name, interface_index); + // New service found, resolve it + auto* ctx = new ResolveContext(); + ctx->impl = impl; + ctx->service_name = service_name; + + if (DNSServiceResolve(&ctx->resolve_ref, 0, interface_index, service_name, reg_type, + reply_domain, resolve_callback, ctx) + != kDNSServiceErr_NoError) { + delete ctx; + return; + } + std::scoped_lock lock(impl->pending_resolves_mutex); + impl->pending_resolves.insert(ctx); + } else { + // Service disappeared + DEBUG_LOG("service removed: %s", service_name); + impl->delegate.on_service_down(service_name); + } +} + +void DNSSD_API DnsSdPlatformBrowser::Impl::resolve_callback(DNSServiceRef, DNSServiceFlags, + uint32_t, DNSServiceErrorType error_code, const char*, const char* hosttarget, uint16_t port, + uint16_t txt_len, const unsigned char* txt_record, void* context) +{ + auto* ctx = static_cast(context); + if (error_code != kDNSServiceErr_NoError || !ctx->impl->is_started) { + ctx->destroyed = true; + return; + } + + DnsSdService info; + info.name = ctx->service_name; + info.host = hosttarget; + info.port = ntohs(port); + + // Parse TXT record into key-value map + { + uint16_t count = TXTRecordGetCount(txt_len, txt_record); + for (uint16_t i = 0; i < count; i++) { + char key[256]; + uint8_t value_len = 0; + const void* value = nullptr; + auto err = TXTRecordGetItemAtIndex( + txt_len, txt_record, i, sizeof(key), key, &value_len, &value); + if (err == kDNSServiceErr_NoError) { + info.txt_record[key] = (value && value_len > 0) + ? std::string(static_cast(value), value_len) + : ""; + } + } + } + + // Resolve v4/v6 addresses via getaddrinfo + resolve_addresses(hosttarget, info.address4, info.address6); + + DEBUG_LOG("resolved: %s -> %s:%d (%s / %s)", info.name.c_str(), info.host.c_str(), info.port, + info.address4.c_str(), info.address6.c_str()); + ctx->impl->delegate.on_service_up(info); + ctx->destroyed = true; +} diff --git a/bridge/src/dns_sd/native/dns_sd_platform_browser_win.cc b/bridge/src/dns_sd/native/dns_sd_platform_browser_win.cc new file mode 100644 index 0000000..df1135c --- /dev/null +++ b/bridge/src/dns_sd/native/dns_sd_platform_browser_win.cc @@ -0,0 +1,362 @@ +/** + * DNS-SD browser implementation for Windows. + * + * Uses the DNS-SD functions of the Windows DNS API (Windns.h) available on Windows 10+ without any + * third-party dependencies like Bonjour. + */ + +#include "dns_sd_platform_browser.h" + +#define NOMINMAX + +#include "utils.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +/** Convert a wide string to a UTF-8 std::string. */ +std::string wide_to_utf8(const wchar_t* wide) +{ + if (!wide) + return ""; + int len = WideCharToMultiByte(CP_UTF8, 0, wide, -1, nullptr, 0, nullptr, nullptr); + if (len <= 0) + return ""; + std::string result(len - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, wide, -1, &result[0], len, nullptr, nullptr); + return result; +} + +/** Convert a UTF-8 std::string to a wide string. */ +std::wstring utf8_to_wide(const std::string& utf8) +{ + if (utf8.empty()) + return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, nullptr, 0); + if (len <= 0) + return L""; + std::wstring result(len - 1, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, &result[0], len); + return result; +} + +} // anonymous namespace + +struct DnsSdPlatformBrowser::Impl { + /* Service type for browse operation. */ + std::string service_type; + /* Delegate to receive browser events. */ + DnsSdPlatformBrowserDelegate& delegate; + + /** Represents the current browse operation. */ + struct BrowseContext { + /** Query name (owns storage for `request.QueryName`). */ + std::wstring query_name; + DNS_SERVICE_BROWSE_REQUEST request; + DNS_SERVICE_CANCEL cancel; + + BrowseContext() + : request {} + , cancel {} + { + } + }; + /* Whether browse operation is ongoing. */ + std::atomic is_started; + BrowseContext browse; + + /** Represents a resolve operation triggered by the current browse operation. */ + struct ResolveContext { + Impl* impl; + bool cancelled; + DWORD ttl; + std::string service_name; + std::wstring query_name; + DNS_SERVICE_RESOLVE_REQUEST resolve_request; + DNS_SERVICE_CANCEL resolve_cancel; + + ResolveContext() + : impl(nullptr) + , cancelled(false) + , ttl(0) + , resolve_request {} + , resolve_cancel {} + { + } + }; + // Stored contexts for ongoing resolve operations + std::set active_resolves; + std::mutex active_resolves_mutex; + + // WinDNS doesn't have a builtin mechanism to notify us when a service disappears, so we + // keep a record of found services and expire them (emitting a `service_down` event) based on + // their TTL unless they're refreshed by a subsequent browse callback. + std::map expiring_services; + std::mutex expiring_services_mutex; + std::thread expiry_thread; + std::condition_variable expiry_cv; + + Impl(const std::string& type, DnsSdPlatformBrowserDelegate& del) + : service_type(type) + , delegate(del) + , is_started(false) + { + } + + ~Impl() { stop(); } + + void start(); + void stop(); + void expiry_loop(); + + static void WINAPI browse_callback(DWORD status, PVOID context, PDNS_RECORD query_results); + + static void WINAPI resolve_callback( + DWORD status, PVOID context, PDNS_SERVICE_INSTANCE service_instance); +}; + +DnsSdPlatformBrowser::DnsSdPlatformBrowser( + const std::string& service_type, DnsSdPlatformBrowserDelegate& delegate) + : impl_(std::make_unique(service_type, delegate)) +{ +} + +DnsSdPlatformBrowser::~DnsSdPlatformBrowser() = default; + +void DnsSdPlatformBrowser::start() { impl_->start(); } + +void DnsSdPlatformBrowser::stop() { impl_->stop(); } + +void DnsSdPlatformBrowser::Impl::start() +{ + if (is_started) + return; + is_started = true; + + WSADATA wsa_data; + WSAStartup(MAKEWORD(2, 2), &wsa_data); + + // Windns expects service name with a .local suffix + browse.query_name = utf8_to_wide(service_type + ".local"); + + browse.request.Version = DNS_QUERY_REQUEST_VERSION1; + browse.request.InterfaceIndex = 0; + browse.request.QueryName = browse.query_name.c_str(); + browse.request.pBrowseCallback = browse_callback; + browse.request.pQueryContext = this; + + DNS_STATUS status = DnsServiceBrowse(&browse.request, &browse.cancel); + if (status != DNS_REQUEST_PENDING && status != ERROR_SUCCESS) { + ERROR_LOG("browse failed with status %lu", status); + is_started = false; + return; + } + + DEBUG_LOG("browse started for %s", service_type.c_str()); + + // Start expiry loop on background thread + expiry_thread = std::thread(&Impl::expiry_loop, this); +} + +void DnsSdPlatformBrowser::Impl::stop() +{ + if (!is_started) + return; + is_started = false; + + // Cancel browse operation + DnsServiceBrowseCancel(&browse.cancel); + + // Cancel and cleanup active resolves + { + std::scoped_lock lock(active_resolves_mutex); + for (auto* ctx : active_resolves) { + if (!ctx->cancelled) { + ctx->cancelled = true; + DnsServiceResolveCancel(&ctx->resolve_cancel); + } + } + active_resolves.clear(); + } + + // Wake and join expiry thread + expiry_cv.notify_all(); + if (expiry_thread.joinable()) + expiry_thread.join(); + { + std::scoped_lock lock(expiring_services_mutex); + expiring_services.clear(); + } + + WSACleanup(); +} + +void DnsSdPlatformBrowser::Impl::expiry_loop() +{ + std::unique_lock lock(expiring_services_mutex); + while (is_started) { + // Check for expired services and calculate the next expiry time (if any) from the tracked + // expiring services. + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + std::chrono::steady_clock::time_point next_expiry + = std::chrono::steady_clock::time_point::max(); + for (auto it = expiring_services.begin(); it != expiring_services.end();) { + auto& [name, expires_at] = *it; + if (expires_at <= now) { + // Service expired without a new browse result, so we should treat this as the + // service becoming unavailable and emit a `service_down` event. + DEBUG_LOG("service expired: %s", name.c_str()); + std::string expired_name = name; + it = expiring_services.erase(it); + lock.unlock(); + delegate.on_service_down(expired_name); + lock.lock(); + } else { + // Update expiry time if this service expires sooner than the current next_expiry. + auto expiry_s + = std::chrono::duration_cast(expires_at - now).count(); + DEBUG_LOG("service %s expires in %llds", name.c_str(), expiry_s); + if (expires_at < next_expiry) + next_expiry = expires_at; + ++it; + } + } + + if (next_expiry == std::chrono::steady_clock::time_point::max()) { + // Wait until browse operation stopped (which subsequently ends the loop at this + // iteration) or a new service is added (which updates the expiry time). + expiry_cv.wait(lock, [&] { return !is_started || !expiring_services.empty(); }); + } else { + // Wait until the next service expiry time + expiry_cv.wait_until(lock, next_expiry); + } + } +} + +void WINAPI DnsSdPlatformBrowser::Impl::browse_callback( + DWORD status, PVOID context, PDNS_RECORD query_results) +{ + auto* impl = static_cast(context); + + ScopeGuard free_records { [&] { + if (query_results) + DnsRecordListFree(query_results, DnsFreeRecordList); + } }; + + if (!impl->is_started) + return; + if (status != ERROR_SUCCESS || !query_results) + return; + + // Walk the record chain for PTR records (representing service instances) + for (PDNS_RECORD record = query_results; record; record = record->pNext) { + if (record->wType != DNS_TYPE_PTR) + continue; + + auto* resolve_ctx = new ResolveContext(); + resolve_ctx->impl = impl; + resolve_ctx->ttl = record->dwTtl; + + std::string instance_name = wide_to_utf8(record->Data.PTR.pNameHost); + DEBUG_LOG("browse found: %s (ttl=%lu)", instance_name.c_str(), record->dwTtl); + + // Strip everything after (and including) the first dot to get the service name + size_t dot = instance_name.find('.'); + resolve_ctx->service_name + = (dot != std::string::npos) ? instance_name.substr(0, dot) : instance_name; + + resolve_ctx->query_name = utf8_to_wide(instance_name); + + // Populate resolve request + resolve_ctx->resolve_request.Version = DNS_QUERY_REQUEST_VERSION1; + resolve_ctx->resolve_request.InterfaceIndex = 0; + resolve_ctx->resolve_request.QueryName = resolve_ctx->query_name.data(); + resolve_ctx->resolve_request.pResolveCompletionCallback = resolve_callback; + resolve_ctx->resolve_request.pQueryContext = resolve_ctx; + + // Start the resolve operation + { + std::scoped_lock lock(impl->active_resolves_mutex); + DNS_STATUS resolve_status + = DnsServiceResolve(&resolve_ctx->resolve_request, &resolve_ctx->resolve_cancel); + switch (resolve_status) { + case ERROR_SUCCESS: + case DNS_REQUEST_PENDING: + impl->active_resolves.insert(resolve_ctx); + break; + default: + delete resolve_ctx; + break; + } + } + } +} + +void WINAPI DnsSdPlatformBrowser::Impl::resolve_callback( + DWORD status, PVOID context, PDNS_SERVICE_INSTANCE service_instance) +{ + auto* resolve_ctx = static_cast(context); + auto* impl = resolve_ctx->impl; + + ScopeGuard defer { [&] { + if (service_instance) + DnsServiceFreeInstance(service_instance); + { + std::scoped_lock lock(impl->active_resolves_mutex); + impl->active_resolves.erase(resolve_ctx); + } + delete resolve_ctx; + } }; + + // If the browse operation is still active, the resolve operation was not cancelled, and we got + // a valid result, emit a service_up event + if (impl->is_started && !resolve_ctx->cancelled && status == ERROR_SUCCESS + && service_instance) { + DnsSdService service; + service.name = resolve_ctx->service_name; + service.host = wide_to_utf8(service_instance->pszHostName); + service.port = service_instance->wPort; + + // Extract TXT record key-value pairs + if (service_instance->dwPropertyCount > 0 && service_instance->keys + && service_instance->values) { + for (DWORD i = 0; i < service_instance->dwPropertyCount; i++) { + if (service_instance->keys[i]) { + std::string key = wide_to_utf8(service_instance->keys[i]); + std::string value; + if (service_instance->values[i]) + value = wide_to_utf8(service_instance->values[i]); + service.txt_record[key] = value; + } + } + } + + // Resolve v4/v6 addresses via getaddrinfo + resolve_addresses(service.host, service.address4, service.address6); + + if (impl->is_started) { + // Schedule service expiry + { + std::scoped_lock svc_lock(impl->expiring_services_mutex); + impl->expiring_services[service.name] + = std::chrono::steady_clock::now() + std::chrono::seconds(resolve_ctx->ttl); + } + impl->expiry_cv.notify_one(); + + DEBUG_LOG("resolved: %s -> %s:%d (%s / %s, ttl=%lus)", service.name.c_str(), + service.host.c_str(), service.port, service.address4.c_str(), + service.address6.c_str(), resolve_ctx->ttl); + + // Emit service_up event with merged addresses + impl->delegate.on_service_up(service); + } + } +} diff --git a/bridge/src/dns_sd/native/utils.h b/bridge/src/dns_sd/native/utils.h new file mode 100644 index 0000000..1ef9d85 --- /dev/null +++ b/bridge/src/dns_sd/native/utils.h @@ -0,0 +1,55 @@ +#ifndef DNS_SD_UTILS_H_ +#define DNS_SD_UTILS_H_ + +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#endif + +/** Defer-cleanup util class. */ +template struct [[nodiscard]] ScopeGuard { + F fn; + ~ScopeGuard() { fn(); } +}; + +#ifdef DNS_SD_DEBUG +#define DEBUG_LOG(fmt, ...) std::fprintf(stderr, "[dns_sd] " fmt "\n", ##__VA_ARGS__) +#else +#define DEBUG_LOG(fmt, ...) ((void)0) +#endif + +#define ERROR_LOG(fmt, ...) std::fprintf(stderr, "[dns_sd] " fmt "\n", ##__VA_ARGS__) + +/** Resolves a hostname to IPv4/v6 address strings via getaddrinfo. */ +inline void resolve_addresses( + const std::string& hostname, std::string& out_ipv4, std::string& out_ipv6) +{ + addrinfo hints { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM }; + addrinfo* result = nullptr; + if (getaddrinfo(hostname.c_str(), nullptr, &hints, &result) != 0) + return; + + for (addrinfo* p = result; p; p = p->ai_next) { + if (p->ai_family == AF_INET && out_ipv4.empty()) { + char buf[INET_ADDRSTRLEN]; + auto* addr = reinterpret_cast(p->ai_addr); + if (inet_ntop(AF_INET, &addr->sin_addr, buf, sizeof(buf))) + out_ipv4 = buf; + } else if (p->ai_family == AF_INET6 && out_ipv6.empty()) { + char buf[INET6_ADDRSTRLEN]; + auto* addr = reinterpret_cast(p->ai_addr); + if (inet_ntop(AF_INET6, &addr->sin6_addr, buf, sizeof(buf))) + out_ipv6 = buf; + } + } + freeaddrinfo(result); +} + +#endif // DNS_SD_UTILS_H_