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

View File

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

View File

@@ -66,8 +66,19 @@ export default class CastClient {
*/
connect(host: string, options?: CastClientConnectOptions) {
return new Promise<void>((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);

View File

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

View File

@@ -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?.();
}

View File

@@ -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<string, Remote>();
/**
* 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;
}

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_