wip: Replace unmaintained mdns module with a custom native module

This commit is contained in:
hensm
2026-03-01 19:08:29 +00:00
committed by Matt Hensman
parent 5a18907dba
commit 47cc57445e
22 changed files with 1231 additions and 192 deletions

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
node_modules/ node_modules/
dist/ dist/
bridge/node_modules/ bridge/node_modules/
bridge/build bridge/build/
extension/node_modules/ extension/node_modules/
test/ChromeProfile/ test/ChromeProfile/
.idea/ .idea/

23
.vscode/c_cpp_properties.json vendored Normal file
View File

@@ -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
}

View File

@@ -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
},
} }

View File

@@ -58,7 +58,7 @@ if (!supportedTargets[process.platform]?.includes(argv.arch)) {
const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const ROOT_PATH = path.join(__dirname, ".."); 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 = { const spawnOptions = {
shell: true, shell: true,
@@ -70,15 +70,14 @@ const spawnOptions = {
* build directories, just in case. * build directories, just in case.
*/ */
fs.rmSync(BUILD_PATH, { force: true, recursive: true }); 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(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( const NATIVE_BINDING_PATH = path.join(ROOT_PATH, "build/Release");
__dirname, const NATIVE_BINDING_NAME = "dns_sd.node";
"../node_modules/mdns/build/Release/"
);
const MDNS_BINDING_NAME = "dns_sd_bindings.node";
async function build() { async function build() {
// Run tsc // Run tsc
@@ -140,8 +139,8 @@ async function build() {
]); ]);
fs.copySync( fs.copySync(
path.join(MDNS_BINDING_PATH, MDNS_BINDING_NAME), path.join(NATIVE_BINDING_PATH, NATIVE_BINDING_NAME),
path.join(BUILD_PATH, MDNS_BINDING_NAME) path.join(BUILD_PATH, NATIVE_BINDING_NAME)
); );
fs.rmSync(path.join(BUILD_PATH, "src"), { 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)); 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 // Write app manifest
fs.writeFileSync( fs.writeFileSync(
path.join(BUILD_PATH, paths.MANIFEST_NAME), path.join(BUILD_PATH, paths.MANIFEST_NAME),
@@ -318,8 +329,8 @@ function packageDarwin(
path.join(rootExecutableDirectory, platformExecutableName) path.join(rootExecutableDirectory, platformExecutableName)
); );
fs.moveSync( fs.moveSync(
path.join(BUILD_PATH, MDNS_BINDING_NAME), path.join(BUILD_PATH, NATIVE_BINDING_NAME),
path.join(rootExecutableDirectory, MDNS_BINDING_NAME) path.join(rootExecutableDirectory, NATIVE_BINDING_NAME)
); );
fs.moveSync( fs.moveSync(
path.join(BUILD_PATH, paths.MANIFEST_NAME), path.join(BUILD_PATH, paths.MANIFEST_NAME),
@@ -416,8 +427,8 @@ function packageLinuxDeb(
path.join(rootExecutableDirectory, platformExecutableName) path.join(rootExecutableDirectory, platformExecutableName)
); );
fs.moveSync( fs.moveSync(
path.join(BUILD_PATH, MDNS_BINDING_NAME), path.join(BUILD_PATH, NATIVE_BINDING_NAME),
path.join(rootExecutableDirectory, MDNS_BINDING_NAME) path.join(rootExecutableDirectory, NATIVE_BINDING_NAME)
); );
fs.moveSync( fs.moveSync(
path.join(BUILD_PATH, paths.MANIFEST_NAME), path.join(BUILD_PATH, paths.MANIFEST_NAME),
@@ -490,7 +501,7 @@ function packageLinuxRpm(
manifestPath: platformManifestDirectory, manifestPath: platformManifestDirectory,
executableName: platformExecutableName, executableName: platformExecutableName,
manifestName: paths.MANIFEST_NAME, manifestName: paths.MANIFEST_NAME,
bindingName: MDNS_BINDING_NAME bindingName: NATIVE_BINDING_NAME
}; };
fs.writeFileSync( fs.writeFileSync(
@@ -539,7 +550,7 @@ function packageWin32(
executableName: platformExecutableName, executableName: platformExecutableName,
executablePath: platformExecutableDirectory, executablePath: platformExecutableDirectory,
manifestName: paths.MANIFEST_NAME, manifestName: paths.MANIFEST_NAME,
bindingName: MDNS_BINDING_NAME, bindingName: NATIVE_BINDING_NAME,
winRegistryKey: paths.REGISTRY_KEY, winRegistryKey: paths.REGISTRY_KEY,
outputName, outputName,
licensePath: paths.LICENSE_PATH, licensePath: paths.LICENSE_PATH,

60
bridge/binding.gyp Normal file
View File

@@ -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": ["<!@(node -p \"require('node-addon-api').include\")"],
"conditions": [
[
"OS=='mac'",
{
"sources": [
"src/dns_sd/native/addon.cc",
"src/dns_sd/native/dns_sd_browser.cc",
"src/dns_sd/native/dns_sd_platform_browser_unix.cc",
],
"cflags_cc": ["-std=c++20"],
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
"CLANG_CXX_LANGUAGE_STANDARD": "c++20",
"CLANG_CXX_LIBRARY": "libc++",
"MACOSX_DEPLOYMENT_TARGET": "10.15",
},
},
],
[
"OS=='linux'",
{
"sources": [
"src/dns_sd/native/addon.cc",
"src/dns_sd/native/dns_sd_browser.cc",
"src/dns_sd/native/dns_sd_platform_browser_unix.cc",
],
"cflags_cc": ["-std=c++20"],
"libraries": ["-ldns_sd"],
},
],
[
"OS=='win'",
{
"sources": [
"src/dns_sd/native/addon.cc",
"src/dns_sd/native/dns_sd_browser.cc",
"src/dns_sd/native/dns_sd_platform_browser_win.cc",
],
"libraries": ["dnsapi.lib", "ws2_32.lib"],
"msvs_settings": {
"VCCLCompilerTool": {
"ExceptionHandling": 1,
"AdditionalOptions": ["/std:c++20"],
}
},
"defines": ["UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN"],
},
],
],
}
]
} # type: ignore

104
bridge/package-lock.json generated
View File

@@ -4,21 +4,22 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"hasInstallScript": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0",
"bplist-creator": "^0.1.0", "bplist-creator": "^0.1.0",
"bplist-parser": "^0.3.1", "bplist-parser": "^0.3.1",
"castv2": "^0.1.10", "castv2": "^0.1.10",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"fast-srp-hap": "^2.0.4", "fast-srp-hap": "^2.0.4",
"mdns": "^2.7.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"node-addon-api": "^8.0.0",
"node-fetch": "^3.2.10", "node-fetch": "^3.2.10",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"ws": "^8.5.0", "ws": "^8.5.0",
"yargs": "^17.5.1" "yargs": "^17.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/mdns": "^0.0.34",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/minimist": "^1.2.2", "@types/minimist": "^1.2.2",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
@@ -32,6 +33,14 @@
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
}, },
"dns_sd": {
"version": "1.0.0",
"extraneous": true,
"hasInstallScript": true,
"dependencies": {
"node-addon-api": "^8.0.0"
}
},
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.16.7", "version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
@@ -160,15 +169,6 @@
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
"integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
}, },
"node_modules/@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,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/mime-types": { "node_modules/@types/mime-types": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
@@ -328,9 +328,13 @@
} }
}, },
"node_modules/bindings": { "node_modules/bindings": {
"version": "1.2.1", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha1-FK1hE4EtLTfXLme0ystLtyZQXxE=" "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
}, },
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
@@ -776,6 +780,12 @@
"node": "^12.20 || >= 14.13" "node": "^12.20 || >= 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": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -1115,16 +1125,6 @@
"node": ">=10" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -1242,11 +1242,6 @@
"mustache": "bin/mustache" "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": { "node_modules/napi-build-utils": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
@@ -1271,6 +1266,15 @@
"semver": "bin/semver" "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": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
"integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" "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": { "@types/mime-types": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
@@ -2522,9 +2517,12 @@
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==" "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg=="
}, },
"bindings": { "bindings": {
"version": "1.2.1", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha1-FK1hE4EtLTfXLme0ystLtyZQXxE=" "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"requires": {
"file-uri-to-path": "1.0.0"
}
}, },
"bl": { "bl": {
"version": "4.1.0", "version": "4.1.0",
@@ -2848,6 +2846,11 @@
"web-streams-polyfill": "^3.0.3" "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": { "fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -3108,15 +3111,6 @@
"yallist": "^4.0.0" "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": { "merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3198,11 +3192,6 @@
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"dev": true "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": { "napi-build-utils": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "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": { "node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",

View File

@@ -2,25 +2,26 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node bin/build.js", "build": "node bin/build.js",
"install": "node-gyp rebuild",
"package": "node bin/build.js --package", "package": "node bin/build.js --package",
"install-manifest": "node bin/install-manifest.js", "install-manifest": "node bin/install-manifest.js",
"remove-manifest": "node bin/install-manifest.js --remove" "remove-manifest": "node bin/install-manifest.js --remove"
}, },
"dependencies": { "dependencies": {
"bindings": "^1.5.0",
"bplist-creator": "^0.1.0", "bplist-creator": "^0.1.0",
"bplist-parser": "^0.3.1", "bplist-parser": "^0.3.1",
"castv2": "^0.1.10", "castv2": "^0.1.10",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"fast-srp-hap": "^2.0.4", "fast-srp-hap": "^2.0.4",
"mdns": "^2.7.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"node-addon-api": "^8.0.0",
"node-fetch": "^3.2.10", "node-fetch": "^3.2.10",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"ws": "^8.5.0", "ws": "^8.5.0",
"yargs": "^17.5.1" "yargs": "^17.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/mdns": "^0.0.34",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/minimist": "^1.2.2", "@types/minimist": "^1.2.2",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",

View File

@@ -35,16 +35,12 @@ SetCompressor /SOLID LZMA
!insertmacro MUI_LANGUAGE "German" !insertmacro MUI_LANGUAGE "German"
# lang:en # lang:en
LangString MSG__INSTALL_BONJOUR ${LANG_ENGLISH} \
"Install Bonjour dependency?"
LangString MSG__FIREFOX_OPEN ${LANG_ENGLISH} \ LangString MSG__FIREFOX_OPEN ${LANG_ENGLISH} \
"Firefox must be closed during uninstallation if the extension is \ "Firefox must be closed during uninstallation if the extension is \
installed. Close Firefox and click $\"Retry$\", click $\"Ignore$\" \ installed. Close Firefox and click $\"Retry$\", click $\"Ignore$\" \
to force close or $\"Abort$\" to cancel uninstallation." to force close or $\"Abort$\" to cancel uninstallation."
# lang:es # lang:es
LangString MSG__INSTALL_BONJOUR ${LANG_SPANISH} \
"¿Instalar dependencia Bonjour?"
LangString MSG__FIREFOX_OPEN ${LANG_SPANISH} \ LangString MSG__FIREFOX_OPEN ${LANG_SPANISH} \
"Firefox debe estar cerrado durante la desinstalación si la extensión \ "Firefox debe estar cerrado durante la desinstalación si la extensión \
está instalada. Cierra Firefox y aprieta $\"Reintentar$\", aprieta \ está instalada. Cierra Firefox y aprieta $\"Reintentar$\", aprieta \
@@ -52,8 +48,6 @@ LangString MSG__FIREFOX_OPEN ${LANG_SPANISH} \
desinstalación." desinstalación."
# lang:de # lang:de
LangString MSG__INSTALL_BONJOUR ${LANG_GERMAN} \
"Bonjour installieren?"
LangString MSG__FIREFOX_OPEN ${LANG_GERMAN} \ LangString MSG__FIREFOX_OPEN ${LANG_GERMAN} \
"Firefox muss während der Deinstallation geschlossen werden, wenn die \ "Firefox muss während der Deinstallation geschlossen werden, wenn die \
Erweiterung installiert ist. Schließen Sie Firefox und klicken Sie auf \ Erweiterung installiert ist. Schließen Sie Firefox und klicken Sie auf \
@@ -86,23 +80,6 @@ Section
File "{{bindingName}}" File "{{bindingName}}"
File "{{manifestName}}" 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 # Native manifest key
WriteRegStr HKLM "${KEY_MANIFEST}" "" "$INSTDIR\{{manifestName}}" WriteRegStr HKLM "${KEY_MANIFEST}" "" "$INSTDIR\{{manifestName}}"

View File

@@ -180,7 +180,8 @@ export default class Session extends CastClient {
type: "LAUNCH", type: "LAUNCH",
appId: this.appId appId: this.appId
}); });
}); })
.catch(() => {});
// Handle client connection closed // Handle client connection closed
this.client.on("close", () => { this.client.on("close", () => {

View File

@@ -66,8 +66,19 @@ export default class CastClient {
*/ */
connect(host: string, options?: CastClientConnectOptions) { connect(host: string, options?: CastClientConnectOptions) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
let connected = false;
// Handle errors // 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", () => { this.client.on("close", () => {
if (this.heartbeatChannel && this.heartbeatIntervalId) { if (this.heartbeatChannel && this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId); clearInterval(this.heartbeatIntervalId);
@@ -84,6 +95,7 @@ export default class CastClient {
}, },
// On connection callback // On connection callback
() => { () => {
connected = true;
this.connectionChannel = this.createChannel(NS_CONNECTION); this.connectionChannel = this.createChannel(NS_CONNECTION);
this.heartbeatChannel = this.createChannel(NS_HEARTBEAT); this.heartbeatChannel = this.createChannel(NS_HEARTBEAT);

View File

@@ -1,9 +1,9 @@
import mdns from "mdns"; import { EventEmitter } from "events";
import { DnsSdBrowser } from "../../../dns_sd";
import type { ReceiverDevice } from "../../messagingTypes"; import type { ReceiverDevice } from "../../messagingTypes";
/** /**
* Chromecast TXT record * Chromecast TXT record fields.
*/ */
interface CastRecord { interface CastRecord {
// Device ID // Device ID
@@ -27,54 +27,47 @@ interface CastRecord {
rs: string; rs: string;
} }
interface DiscoveryOptions { export default class CastDeviceBrowser extends EventEmitter<{
onDeviceFound(device: ReceiverDevice): void; deviceUp: [device: ReceiverDevice];
onDeviceDown(deviceId: string): void; deviceDown: [deviceId: string];
} }> {
browser = new DnsSdBrowser("_googlecast._tcp");
export default class Discovery { constructor() {
browser = mdns.createBrowser(mdns.tcp("googlecast"), { super();
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) {
/** /**
* When a service is found, gather device info from service * When a service is found, gather device info from service object and
* object and TXT record, then send a `main:deviceUp` message. * TXT record, then send a `main:deviceUp` message.
*/ */
this.browser.on("serviceUp", service => { this.browser.on("serviceUp", service => {
// Filter invalid results // Filter invalid results
if (!service.txtRecord || !service.name) return; 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 = { const device: ReceiverDevice = {
id: record.id, id: record.id,
friendlyName: record.fn, friendlyName: record.fn,
modelName: record.md, modelName: record.md,
capabilities: parseInt(record.ca), capabilities: parseInt(record.ca),
host: service.addresses[0], host: address,
port: service.port port: service.port
}; };
opts.onDeviceFound(device); this.emit("deviceUp", device);
}); });
/** /**
* When a service is lost, send a `main:deviceDown` message with * When a service is lost, send a `main:deviceDown` message with the
* the service name as the `deviceId`. * service name as the `deviceId`.
*/ */
this.browser.on("serviceDown", service => { this.browser.on("serviceDown", name => {
// Filter invalid results // Filter invalid results
if (!service.name) return; if (!name) return;
opts.onDeviceDown(service.name); this.emit("deviceDown", name);
}); });
} }

View File

@@ -35,7 +35,8 @@ export default class Remote extends CastClient {
}) })
.then(() => { .then(() => {
this.sendReceiverMessage({ type: "GET_STATUS" }); this.sendReceiverMessage({ type: "GET_STATUS" });
}); })
.catch(() => {});
} }
disconnect() { disconnect() {
@@ -85,7 +86,8 @@ export default class Remote extends CastClient {
type: "GET_STATUS", type: "GET_STATUS",
requestId: 0 requestId: 0
}); });
}); })
.catch(() => {});
this.options?.onApplicationFound?.(); this.options?.onApplicationFound?.();
} }

View File

@@ -1,7 +1,7 @@
import type { Messenger, Message } from "./messaging"; import type { Messenger, Message } from "./messaging";
import { handleCastMessage } from "./components/cast"; import { handleCastMessage } from "./components/cast";
import Discovery from "./components/cast/discovery"; import CastDeviceBrowser from "./components/cast/deviceBrowser";
import Remote from "./components/cast/remote"; import Remote from "./components/cast/remote";
import { startMediaServer, stopMediaServer } from "./components/mediaServer"; import { startMediaServer, stopMediaServer } from "./components/mediaServer";
@@ -9,7 +9,7 @@ import { startMediaServer, stopMediaServer } from "./components/mediaServer";
import { applicationVersion } from "../../config.json"; import { applicationVersion } from "../../config.json";
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
discovery?.stop(); deviceBrowser?.stop();
try { try {
await stopMediaServer(); await stopMediaServer();
} catch (err) { } catch (err) {
@@ -19,15 +19,15 @@ process.on("SIGTERM", async () => {
} }
}); });
let discovery: Discovery | null = null; let deviceBrowser: CastDeviceBrowser | null = null;
const remotes = new Map<string, Remote>(); const remotes = new Map<string, Remote>();
/** /**
* Handle incoming messages from the extension and forward * Handle incoming messages from the extension and forward them to the
* them to the appropriate handlers. * appropriate handlers.
* *
* Initializes the counterpart objects and is responsible * Initializes the counterpart objects and is responsible for managing existing
* for managing existing ones. * ones.
*/ */
export function run(messaging: Messenger) { export function run(messaging: Messenger) {
messaging.on("message", (message: Message) => { messaging.on("message", (message: Message) => {
@@ -41,66 +41,66 @@ export function run(messaging: Messenger) {
case "bridge:startDiscovery": { case "bridge:startDiscovery": {
const { shouldWatchStatus } = message.data; const { shouldWatchStatus } = message.data;
discovery = new Discovery({ deviceBrowser = new CastDeviceBrowser();
onDeviceFound(device) {
messaging.sendMessage({
subject: "main:deviceUp",
data: {
deviceId: device.id,
deviceInfo: device
}
});
if (shouldWatchStatus) { deviceBrowser.on("deviceUp", device => {
remotes.set( messaging.sendMessage({
device.id, subject: "main:deviceUp",
new Remote(device.host, { data: {
port: device.port, deviceId: device.id,
// RECEIVER_STATUS deviceInfo: device
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
}
});
}
})
);
} }
}, });
onDeviceDown(deviceId) {
messaging.sendMessage({
subject: "main:deviceDown",
data: { deviceId }
});
if (shouldWatchStatus) { if (shouldWatchStatus) {
if (remotes.has(deviceId)) { remotes.set(
remotes.get(deviceId)?.disconnect(); device.id,
remotes.delete(deviceId); 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; break;
} }

View File

@@ -0,0 +1,3 @@
BasedOnStyle: Webkit
ColumnLimit: 100
SortIncludes: false

View File

@@ -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<string, string>;
}
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<DnsSdBrowserEvents> {
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;
}
}
}

View File

@@ -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)

View File

@@ -0,0 +1,119 @@
#include "dns_sd_browser.h"
DnsSdBrowser::DnsSdBrowser(const Napi::CallbackInfo& info)
: Napi::ObjectWrap<DnsSdBrowser>(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<Napi::String>().Utf8Value();
tsfn_ = Napi::ThreadSafeFunction::New(
env, info[1].As<Napi::Function>(), "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<DnsSdPlatformBrowser>(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<DnsSdService>(service);
napi_status status = tsfn_.NonBlockingCall(
data.get(), [](Napi::Env env, Napi::Function js_callback, DnsSdService* raw) {
std::unique_ptr<DnsSdService> 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<std::string>(name);
napi_status status = tsfn_.NonBlockingCall(
data.get(), [](Napi::Env env, Napi::Function js_callback, std::string* raw) {
std::unique_ptr<std::string> owned(raw);
js_callback.Call(
{ Napi::String::New(env, "serviceDown"), Napi::String::New(env, *owned) });
});
if (status == napi_ok)
data.release();
}

View File

@@ -0,0 +1,30 @@
#ifndef DNS_SD_BROWSER_H
#define DNS_SD_BROWSER_H
#include "dns_sd_platform_browser.h"
#include <memory>
#include <napi.h>
#include <string>
class DnsSdBrowser : public Napi::ObjectWrap<DnsSdBrowser>, 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<DnsSdPlatformBrowser> browser_;
bool started_;
};
#endif // DNS_SD_BROWSER_H

View File

@@ -0,0 +1,49 @@
#ifndef DNS_SD_PLATFORM_BROWSER_H
#define DNS_SD_PLATFORM_BROWSER_H
#include <map>
#include <memory>
#include <mutex>
#include <string>
/**
* 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<std::string, std::string> 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> impl_;
};
#endif // DNS_SD_PLATFORM_BROWSER_H

View File

@@ -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 <dns_sd.h>
#include <atomic>
#include <cstring>
#include <set>
#include <thread>
struct DnsSdPlatformBrowser::Impl {
struct ResolveContext;
std::string service_type;
DnsSdPlatformBrowserDelegate& delegate;
DNSServiceRef browse_ref;
std::atomic<bool> is_started;
std::thread event_loop_thread;
std::mutex pending_resolves_mutex;
std::set<ResolveContext*> 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<Impl>(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<Impl*>(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<ResolveContext*>(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<const char*>(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;
}

View File

@@ -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 <windns.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <map>
#include <set>
#include <thread>
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<bool> 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<ResolveContext*> 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<std::string, std::chrono::steady_clock::time_point> 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<Impl>(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<std::chrono::seconds>(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<Impl*>(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<ResolveContext*>(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);
}
}
}

View File

@@ -0,0 +1,55 @@
#ifndef DNS_SD_UTILS_H_
#define DNS_SD_UTILS_H_
#include <cstdio>
#include <string>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#else
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#endif
/** Defer-cleanup util class. */
template <typename F> 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<sockaddr_in*>(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<sockaddr_in6*>(p->ai_addr);
if (inet_ntop(AF_INET6, &addr->sin6_addr, buf, sizeof(buf)))
out_ipv6 = buf;
}
}
freeaddrinfo(result);
}
#endif // DNS_SD_UTILS_H_