From 8097f63499221c1afbfc08f3d20050ee746c485c Mon Sep 17 00:00:00 2001 From: Wissem BOUSSETTA Date: Wed, 9 Sep 2026 20:37:00 +0200 Subject: [PATCH 1/6] Scope device store per network with versioned migration --- include/linkpulse/device_store.h | 23 +- src/platform/win32/device_store_win32.c | 349 ++++++++++++++++++++---- src/ui/tray_win32.c | 31 ++- 3 files changed, 342 insertions(+), 61 deletions(-) diff --git a/include/linkpulse/device_store.h b/include/linkpulse/device_store.h index f2e9aea..e326e0d 100644 --- a/include/linkpulse/device_store.h +++ b/include/linkpulse/device_store.h @@ -2,15 +2,30 @@ #define LINKPULSE_DEVICE_STORE_H #include "linkpulse/discovery.h" +#include "linkpulse/isp.h" + +/* Identifies which physical network (by gateway MAC) an observation belongs + to, so devices and ISP identity stay scoped per-router instead of pooled + into one global list across every network ever connected to. */ +typedef struct { + const char *gateway_mac; + const char *gateway_hostname; + const char *gateway_vendor; + const lp_isp_info_t *isp; /* NULL when not yet known this poll */ +} lp_network_context_t; /* Opens the per-user MAC-keyed XML device store. The returned handle is opaque. */ int lp_win32_device_store_open(void **store); -/* Applies persisted label, trust, type, and icon metadata to an observation. */ -void lp_win32_device_store_apply(void *store, lp_neighbor_t *neighbor); +/* Applies persisted label, trust, type, and icon metadata for the given + network's device to an observation. gateway_mac may be NULL/empty for an + unresolved network. */ +void lp_win32_device_store_apply(void *store, const char *gateway_mac, lp_neighbor_t *neighbor); -/* Records observed metadata and persists the store atomically. */ -void lp_win32_device_store_observe(void *store, const lp_neighbor_t *neighbor); +/* Records observed device and ISP metadata under the given network and + persists the store atomically. */ +void lp_win32_device_store_observe(void *store, const lp_network_context_t *network_context, + const lp_neighbor_t *neighbor); /* Releases the store and any XML resources owned by it. */ void lp_win32_device_store_close(void *store); diff --git a/src/platform/win32/device_store_win32.c b/src/platform/win32/device_store_win32.c index a2b4442..589d429 100644 --- a/src/platform/win32/device_store_win32.c +++ b/src/platform/win32/device_store_win32.c @@ -22,8 +22,13 @@ #include #include -#define LP_DEVICE_STORE_MAX 256 +/* Bump when the on-disk schema changes, and add a migration step below (see + the comment above load_store) so older files upgrade in place at startup. */ +#define LP_DEVICE_STORE_SCHEMA_VERSION 2 #define LP_DEVICE_STORE_PATH_MAX MAX_PATH +/* Key used for devices/network context seen without a resolved gateway MAC, + and for legacy (pre-v2, flat) records migrated from a single global list. */ +#define LP_DEVICE_STORE_UNKNOWN_NETWORK_KEY "" typedef struct { char mac[LP_MAC_STR_MAX]; @@ -38,10 +43,26 @@ typedef struct { unsigned long long last_seen; } lp_device_record_t; +/* One physical network (identified by its gateway's MAC address), so the same + laptop connecting to different routers - home, work, a relative's house - + keeps separate device lists and ISP identities instead of one global mix. */ +typedef struct { + char gateway_mac[LP_MAC_STR_MAX]; + char gateway_hostname[LP_HOSTNAME_MAX]; + char gateway_vendor[LP_VENDOR_MAX]; + lp_isp_info_t isp; + unsigned long long first_seen; + unsigned long long last_seen; + lp_device_record_t *devices; + size_t device_count; + size_t device_capacity; +} lp_network_record_t; + typedef struct { char path[LP_DEVICE_STORE_PATH_MAX]; - lp_device_record_t records[LP_DEVICE_STORE_MAX]; - size_t count; + lp_network_record_t *networks; + size_t network_count; + size_t network_capacity; } lp_device_store_t; static void copy_text(char *out, size_t cap, const char *value) @@ -128,14 +149,68 @@ static unsigned long long now_seconds(void) return now > 0 ? (unsigned long long)now : 0; } -static long find_record(const lp_device_store_t *store, const char *mac) +static lp_network_record_t *find_network(lp_device_store_t *store, const char *gateway_mac) +{ + for (size_t i = 0; i < store->network_count; ++i) { + if (_stricmp(store->networks[i].gateway_mac, gateway_mac) == 0) { + return &store->networks[i]; + } + } + return NULL; +} + +/* Grows without a fixed cap: occasional travel between networks (home, work, + a relative's house) must never silently stop being tracked. */ +static lp_network_record_t *find_or_create_network(lp_device_store_t *store, const char *gateway_mac) +{ + lp_network_record_t *existing = find_network(store, gateway_mac); + if (existing != NULL) { + return existing; + } + if (store->network_count >= store->network_capacity) { + const size_t new_capacity = store->network_capacity == 0 ? 4 : store->network_capacity * 2; + lp_network_record_t *grown = realloc(store->networks, new_capacity * sizeof(*grown)); + if (grown == NULL) { + return NULL; + } + store->networks = grown; + store->network_capacity = new_capacity; + } + lp_network_record_t *network = &store->networks[store->network_count++]; + memset(network, 0, sizeof(*network)); + copy_text(network->gateway_mac, sizeof(network->gateway_mac), gateway_mac); + return network; +} + +static lp_device_record_t *find_device(lp_network_record_t *network, const char *mac) +{ + for (size_t i = 0; i < network->device_count; ++i) { + if (_stricmp(network->devices[i].mac, mac) == 0) { + return &network->devices[i]; + } + } + return NULL; +} + +static lp_device_record_t *find_or_create_device(lp_network_record_t *network, const char *mac) { - for (size_t i = 0; i < store->count; ++i) { - if (_stricmp(store->records[i].mac, mac) == 0) { - return (long)i; + lp_device_record_t *existing = find_device(network, mac); + if (existing != NULL) { + return existing; + } + if (network->device_count >= network->device_capacity) { + const size_t new_capacity = network->device_capacity == 0 ? 8 : network->device_capacity * 2; + lp_device_record_t *grown = realloc(network->devices, new_capacity * sizeof(*grown)); + if (grown == NULL) { + return NULL; } + network->devices = grown; + network->device_capacity = new_capacity; } - return -1; + lp_device_record_t *device = &network->devices[network->device_count++]; + memset(device, 0, sizeof(*device)); + copy_text(device->mac, sizeof(device->mac), mac); + return device; } static void apply_record(const lp_device_record_t *record, lp_neighbor_t *neighbor) @@ -200,7 +275,89 @@ static void read_device_attributes(IXmlReader *reader, lp_device_record_t *recor (void)IXmlReader_MoveToElement(reader); } -static void load_store(lp_device_store_t *store) +static void read_network_attributes(IXmlReader *reader, lp_network_record_t *network) +{ + if (IXmlReader_MoveToFirstAttribute(reader) != S_OK) { + return; + } + for (int guard = 0; guard < 64; ++guard) { + const WCHAR *name = NULL; + const WCHAR *value = NULL; + if (FAILED(IXmlReader_GetQualifiedName(reader, &name, NULL)) || + FAILED(IXmlReader_GetValue(reader, &value, NULL))) { + if (IXmlReader_MoveToNextAttribute(reader) != S_OK) break; + continue; + } + char name_utf8[64]; + char value_utf8[LP_HOSTNAME_MAX]; + if (!wide_to_utf8(name, name_utf8, sizeof(name_utf8)) || + !wide_to_utf8(value, value_utf8, sizeof(value_utf8))) { + continue; + } + if (strcmp(name_utf8, "gateway-hostname") == 0) + copy_text(network->gateway_hostname, sizeof(network->gateway_hostname), value_utf8); + else if (strcmp(name_utf8, "gateway-vendor") == 0) + copy_text(network->gateway_vendor, sizeof(network->gateway_vendor), value_utf8); + else if (strcmp(name_utf8, "isp") == 0) + copy_text(network->isp.isp, sizeof(network->isp.isp), value_utf8); + else if (strcmp(name_utf8, "isp-asn") == 0) + copy_text(network->isp.asn, sizeof(network->isp.asn), value_utf8); + else if (strcmp(name_utf8, "isp-public-ip") == 0) + copy_text(network->isp.public_ip, sizeof(network->isp.public_ip), value_utf8); + else if (strcmp(name_utf8, "isp-hostname") == 0) + copy_text(network->isp.hostname, sizeof(network->isp.hostname), value_utf8); + else if (strcmp(name_utf8, "isp-city") == 0) + copy_text(network->isp.city, sizeof(network->isp.city), value_utf8); + else if (strcmp(name_utf8, "isp-region") == 0) + copy_text(network->isp.region, sizeof(network->isp.region), value_utf8); + else if (strcmp(name_utf8, "isp-country") == 0) + copy_text(network->isp.country, sizeof(network->isp.country), value_utf8); + else if (strcmp(name_utf8, "isp-postal") == 0) + copy_text(network->isp.postal, sizeof(network->isp.postal), value_utf8); + else if (strcmp(name_utf8, "isp-timezone") == 0) + copy_text(network->isp.timezone, sizeof(network->isp.timezone), value_utf8); + else if (strcmp(name_utf8, "isp-loc") == 0) + copy_text(network->isp.loc, sizeof(network->isp.loc), value_utf8); + else if (strcmp(name_utf8, "firstSeen") == 0) { + network->first_seen = _strtoui64(value_utf8, NULL, 10); + } else if (strcmp(name_utf8, "lastSeen") == 0) { + network->last_seen = _strtoui64(value_utf8, NULL, 10); + } + /* "gateway-mac" is read separately by the caller before this record + exists, since it is the lookup/creation key. */ + if (IXmlReader_MoveToNextAttribute(reader) != S_OK) { + break; + } + } + (void)IXmlReader_MoveToElement(reader); +} + +/* Reads a single named attribute's value as a UTF-8 string, if present. */ +static bool read_attribute_by_name(IXmlReader *reader, const wchar_t *name, char *out, size_t cap) +{ + out[0] = '\0'; + if (IXmlReader_MoveToAttributeByName(reader, name, NULL) != S_OK) { + return false; + } + const WCHAR *value = NULL; + const bool ok = + SUCCEEDED(IXmlReader_GetValue(reader, &value, NULL)) && wide_to_utf8(value, out, cap); + (void)IXmlReader_MoveToElement(reader); + return ok; +} + +/* + * Schema versioning and migration: + * + * The root element's "version" attribute (absent/1 on the original flat + * layout that shipped in v0.2.0) selects how + * this function interprets the file. To add a future schema change, bump + * LP_DEVICE_STORE_SCHEMA_VERSION, keep this function able to read the OLD + * shape into the CURRENT in-memory structures (as done below for v1), and set + * *migrated so lp_win32_device_store_open() rewrites the file once at startup + * in the new format instead of waiting for the next observed device. + */ +static void load_store(lp_device_store_t *store, bool *migrated) { const HRESULT com_result = CoInitializeEx(NULL, COINIT_MULTITHREADED); const bool uninitialize = SUCCEEDED(com_result); @@ -223,6 +380,10 @@ static void load_store(lp_device_store_t *store) if (uninitialize) CoUninitialize(); return; } + + bool root_seen = false; + int declared_version = 1; + lp_network_record_t *current_network = NULL; XmlNodeType node_type; /* Hard-capped rather than an unconditional while(Read()): guarantees termination even if a reader implementation ever fails to report @@ -240,16 +401,53 @@ static void load_store(lp_device_store_t *store) const WCHAR *name = NULL; if (FAILED(IXmlReader_GetQualifiedName(reader, &name, NULL)) || name == NULL) continue; char name_utf8[64]; - if (!wide_to_utf8(name, name_utf8, sizeof(name_utf8)) || strcmp(name_utf8, "device") != 0) continue; - if (store->count >= LP_DEVICE_STORE_MAX) continue; - lp_device_record_t *record = &store->records[store->count]; - memset(record, 0, sizeof(*record)); - read_device_attributes(reader, record); - if (record->mac[0] != '\0') ++store->count; + if (!wide_to_utf8(name, name_utf8, sizeof(name_utf8))) continue; + + if (!root_seen) { + root_seen = true; + char version_text[16]; + if (read_attribute_by_name(reader, L"version", version_text, sizeof(version_text))) { + declared_version = atoi(version_text); + } + continue; + } + + if (strcmp(name_utf8, "network") == 0) { + char gateway_mac[LP_MAC_STR_MAX]; + if (!read_attribute_by_name(reader, L"gateway-mac", gateway_mac, sizeof(gateway_mac))) { + gateway_mac[0] = '\0'; + } + current_network = find_or_create_network(store, gateway_mac); + if (current_network != NULL) { + read_network_attributes(reader, current_network); + } + } else if (strcmp(name_utf8, "device") == 0) { + /* A device with no enclosing only occurs in a v1 file + (a flat list at the document root); bucket it under the + "unknown network" key so nothing already learned is lost. */ + if (current_network == NULL) { + current_network = + find_or_create_network(store, LP_DEVICE_STORE_UNKNOWN_NETWORK_KEY); + } + if (current_network == NULL) continue; + lp_device_record_t header = {0}; + read_device_attributes(reader, &header); + if (header.mac[0] == '\0') continue; + lp_device_record_t *device = find_or_create_device(current_network, header.mac); + if (device != NULL) { + *device = header; + } + } } IXmlReader_Release(reader); IStream_Release(stream); if (uninitialize) CoUninitialize(); + + if (declared_version < LP_DEVICE_STORE_SCHEMA_VERSION) { + LP_INFO("migrating device store from schema v%d to v%d", declared_version, + LP_DEVICE_STORE_SCHEMA_VERSION); + *migrated = true; + } } static void write_attribute(IXmlWriter *writer, const char *name, const char *value) @@ -289,22 +487,45 @@ static void save_store(const lp_device_store_t *store) return; } (void)IXmlWriter_WriteStartDocument(writer, XmlStandalone_Omit); - (void)IXmlWriter_WriteStartElement(writer, NULL, L"devices", NULL); - for (size_t i = 0; i < store->count; ++i) { - const lp_device_record_t *record = &store->records[i]; + (void)IXmlWriter_WriteStartElement(writer, NULL, L"linkpulse-devices", NULL); + write_attribute(writer, "version", "2"); + for (size_t i = 0; i < store->network_count; ++i) { + const lp_network_record_t *network = &store->networks[i]; char number[32]; - (void)IXmlWriter_WriteStartElement(writer, NULL, L"device", NULL); - write_attribute(writer, "mac", record->mac); - write_attribute(writer, "label", record->label); - write_attribute(writer, "hostname", record->hostname); - write_attribute(writer, "vendor", record->vendor); - write_attribute(writer, "type", type_to_text(record->device_type)); - write_attribute(writer, "icon", record->icon); - write_attribute(writer, "trusted", record->trusted ? "true" : "false"); - _ui64toa(record->first_seen, number, 10); + (void)IXmlWriter_WriteStartElement(writer, NULL, L"network", NULL); + write_attribute(writer, "gateway-mac", network->gateway_mac); + write_attribute(writer, "gateway-hostname", network->gateway_hostname); + write_attribute(writer, "gateway-vendor", network->gateway_vendor); + write_attribute(writer, "isp", network->isp.isp); + write_attribute(writer, "isp-asn", network->isp.asn); + write_attribute(writer, "isp-public-ip", network->isp.public_ip); + write_attribute(writer, "isp-hostname", network->isp.hostname); + write_attribute(writer, "isp-city", network->isp.city); + write_attribute(writer, "isp-region", network->isp.region); + write_attribute(writer, "isp-country", network->isp.country); + write_attribute(writer, "isp-postal", network->isp.postal); + write_attribute(writer, "isp-timezone", network->isp.timezone); + write_attribute(writer, "isp-loc", network->isp.loc); + _ui64toa(network->first_seen, number, 10); write_attribute(writer, "firstSeen", number); - _ui64toa(record->last_seen, number, 10); + _ui64toa(network->last_seen, number, 10); write_attribute(writer, "lastSeen", number); + for (size_t j = 0; j < network->device_count; ++j) { + const lp_device_record_t *record = &network->devices[j]; + (void)IXmlWriter_WriteStartElement(writer, NULL, L"device", NULL); + write_attribute(writer, "mac", record->mac); + write_attribute(writer, "label", record->label); + write_attribute(writer, "hostname", record->hostname); + write_attribute(writer, "vendor", record->vendor); + write_attribute(writer, "type", type_to_text(record->device_type)); + write_attribute(writer, "icon", record->icon); + write_attribute(writer, "trusted", record->trusted ? "true" : "false"); + _ui64toa(record->first_seen, number, 10); + write_attribute(writer, "firstSeen", number); + _ui64toa(record->last_seen, number, 10); + write_attribute(writer, "lastSeen", number); + (void)IXmlWriter_WriteEndElement(writer); + } (void)IXmlWriter_WriteEndElement(writer); } (void)IXmlWriter_WriteEndElement(writer); @@ -327,40 +548,64 @@ int lp_win32_device_store_open(void **store_out) free(store); return 1; } - load_store(store); + bool migrated = false; + load_store(store, &migrated); + if (migrated) { + save_store(store); + } *store_out = store; - LP_DEBUG("device store opened: records=%llu", (unsigned long long)store->count); + LP_DEBUG("device store opened: networks=%llu", (unsigned long long)store->network_count); return 0; } -void lp_win32_device_store_apply(void *opaque, lp_neighbor_t *neighbor) +void lp_win32_device_store_apply(void *opaque, const char *gateway_mac, lp_neighbor_t *neighbor) { lp_device_store_t *store = opaque; if (store == NULL || neighbor == NULL || neighbor->mac[0] == '\0') return; - const long index = find_record(store, neighbor->mac); - if (index >= 0) apply_record(&store->records[index], neighbor); + lp_network_record_t *network = + find_network(store, gateway_mac != NULL ? gateway_mac : LP_DEVICE_STORE_UNKNOWN_NETWORK_KEY); + if (network == NULL) return; + const lp_device_record_t *device = find_device(network, neighbor->mac); + if (device != NULL) apply_record(device, neighbor); } -void lp_win32_device_store_observe(void *opaque, const lp_neighbor_t *neighbor) +void lp_win32_device_store_observe(void *opaque, const lp_network_context_t *network_context, + const lp_neighbor_t *neighbor) { lp_device_store_t *store = opaque; if (store == NULL || neighbor == NULL || neighbor->mac[0] == '\0') return; - long index = find_record(store, neighbor->mac); - if (index < 0) { - if (store->count >= LP_DEVICE_STORE_MAX) return; - index = (long)store->count++; - memset(&store->records[index], 0, sizeof(store->records[index])); - copy_text(store->records[index].mac, sizeof(store->records[index].mac), neighbor->mac); - store->records[index].first_seen = now_seconds(); - } - lp_device_record_t *record = &store->records[index]; - copy_text(record->hostname, sizeof(record->hostname), neighbor->hostname); - copy_text(record->vendor, sizeof(record->vendor), neighbor->vendor); - if (!record->has_device_type && neighbor->device_type != LP_DEVICE_UNKNOWN) { - record->device_type = neighbor->device_type; - record->has_device_type = true; - } - record->last_seen = now_seconds(); + const char *gateway_mac = + (network_context != NULL && network_context->gateway_mac != NULL) + ? network_context->gateway_mac + : LP_DEVICE_STORE_UNKNOWN_NETWORK_KEY; + lp_network_record_t *network = find_or_create_network(store, gateway_mac); + if (network == NULL) return; + if (network->first_seen == 0) network->first_seen = now_seconds(); + network->last_seen = now_seconds(); + if (network_context != NULL) { + if (network_context->gateway_hostname != NULL && network_context->gateway_hostname[0] != '\0') { + copy_text(network->gateway_hostname, sizeof(network->gateway_hostname), + network_context->gateway_hostname); + } + if (network_context->gateway_vendor != NULL && network_context->gateway_vendor[0] != '\0') { + copy_text(network->gateway_vendor, sizeof(network->gateway_vendor), + network_context->gateway_vendor); + } + if (network_context->isp != NULL && network_context->isp->isp[0] != '\0') { + network->isp = *network_context->isp; + } + } + + lp_device_record_t *device = find_or_create_device(network, neighbor->mac); + if (device == NULL) return; + if (device->first_seen == 0) device->first_seen = now_seconds(); + copy_text(device->hostname, sizeof(device->hostname), neighbor->hostname); + copy_text(device->vendor, sizeof(device->vendor), neighbor->vendor); + if (!device->has_device_type && neighbor->device_type != LP_DEVICE_UNKNOWN) { + device->device_type = neighbor->device_type; + device->has_device_type = true; + } + device->last_seen = now_seconds(); save_store(store); } @@ -368,5 +613,9 @@ void lp_win32_device_store_close(void *opaque) { lp_device_store_t *store = opaque; if (store == NULL) return; + for (size_t i = 0; i < store->network_count; ++i) { + free(store->networks[i].devices); + } + free(store->networks); free(store); } diff --git a/src/ui/tray_win32.c b/src/ui/tray_win32.c index 0f0908b..c9f4433 100644 --- a/src/ui/tray_win32.c +++ b/src/ui/tray_win32.c @@ -319,10 +319,28 @@ static DWORD WINAPI discovery_thread_proc(LPVOID param) const lp_status_t status = lp_discovery_poll(&state->discovery, events, LP_DISCOVERY_MAX_EVENTS, &event_count); if (status == LP_OK) { + lp_local_network_list_t networks = {0}; + if (state->discovery.sources.local_networks_fn != NULL) { + (void)state->discovery.sources.local_networks_fn(&networks); + } + /* Scopes the device store to whichever router is currently the + default gateway, so switching networks (home, work, a + relative's house) keeps separate device lists and ISP identity. */ + const char *gateway_mac = ""; + const char *gateway_hostname = ""; + const char *gateway_vendor = ""; + for (size_t i = 0; i < networks.count; ++i) { + if (networks.items[i].gateway_mac[0] != '\0') { + gateway_mac = networks.items[i].gateway_mac; + gateway_hostname = networks.items[i].gateway_hostname; + gateway_vendor = networks.items[i].gateway_vendor; + break; + } + } /* Applies previously learned identity to notifications/logging too, not just the map, so a transient DNS miss doesn't hide a known name. */ for (size_t i = 0; i < event_count; ++i) { - lp_win32_device_store_apply(state->device_store, &events[i].neighbor); + lp_win32_device_store_apply(state->device_store, gateway_mac, &events[i].neighbor); } size_t active_count = 0; for (size_t i = 0; i < state->discovery.known_count; ++i) { @@ -342,19 +360,18 @@ static DWORD WINAPI discovery_thread_proc(LPVOID param) events[i].neighbor.hostname[0] != '\0' ? events[i].neighbor.hostname : "(unknown)"); } - lp_local_network_list_t networks = {0}; - if (state->discovery.sources.local_networks_fn != NULL) { - (void)state->discovery.sources.local_networks_fn(&networks); - } EnterCriticalSection(&state->lock); + const lp_network_context_t network_context = { + gateway_mac, gateway_hostname, gateway_vendor, + state->isp_available ? &state->isp_info : NULL}; state->map_neighbors.count = 0; for (size_t i = 0; i < state->discovery.known_count && state->map_neighbors.count < LP_DISCOVERY_MAX_NEIGHBORS; ++i) { if (state->discovery.known[i].active) { lp_neighbor_t neighbor = state->discovery.known[i]; - lp_win32_device_store_apply(state->device_store, &neighbor); - lp_win32_device_store_observe(state->device_store, &neighbor); + lp_win32_device_store_apply(state->device_store, gateway_mac, &neighbor); + lp_win32_device_store_observe(state->device_store, &network_context, &neighbor); state->map_neighbors.items[state->map_neighbors.count++] = neighbor; } } From 7a154fccbf879778a5779792135728d2b68ad7ff Mon Sep 17 00:00:00 2001 From: Wissem BOUSSETTA Date: Thu, 10 Sep 2026 17:30:08 +0200 Subject: [PATCH 2/6] Refresh ISP lookup immediately on gateway change --- src/ui/tray_win32.c | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/ui/tray_win32.c b/src/ui/tray_win32.c index c9f4433..dc581b7 100644 --- a/src/ui/tray_win32.c +++ b/src/ui/tray_win32.c @@ -61,6 +61,8 @@ typedef struct { char update_version[LP_UPDATE_VERSION_MAX]; bool isp_available; lp_isp_info_t isp_info; + char last_gateway_mac[LP_MAC_STR_MAX]; + bool last_gateway_mac_set; lp_sampler_t sampler; lp_discovery_t discovery; @@ -72,6 +74,7 @@ typedef struct { HANDLE isp_thread; HANDLE download_thread; HANDLE update_stop_event; + HANDLE isp_refresh_event; lp_discovery_event_t discovery_events[LP_DISCOVERY_MAX_EVENTS]; size_t discovery_event_count; lp_neighbor_list_t map_neighbors; @@ -270,7 +273,9 @@ static DWORD WINAPI update_thread_proc(LPVOID param) return 0; } -/* Looks up the public IP/ISP on a long interval; failures are silently retried. */ +/* Looks up the public IP/ISP on a long interval, or immediately when the + discovery thread signals isp_refresh_event after seeing the gateway MAC + change (e.g. switching Wi-Fi networks); failures are silently retried. */ static DWORD WINAPI isp_thread_proc(LPVOID param) { lp_tray_state_t *state = (lp_tray_state_t *)param; @@ -299,10 +304,13 @@ static DWORD WINAPI isp_thread_proc(LPVOID param) LP_WARN("ISP lookup failed: %s", lp_status_str(status)); } - if (WaitForSingleObject(state->update_stop_event, LP_ISP_LOOKUP_INTERVAL_MS) == - WAIT_OBJECT_0) { - break; + const HANDLE wait_handles[2] = {state->update_stop_event, state->isp_refresh_event}; + const DWORD wait_result = WaitForMultipleObjects(2, wait_handles, FALSE, + LP_ISP_LOOKUP_INTERVAL_MS); + if (wait_result == WAIT_OBJECT_0) { + break; /* stop event */ } + /* WAIT_OBJECT_0 + 1 (refresh) or WAIT_TIMEOUT both fall through to loop and re-lookup. */ } LP_DEBUG("ISP lookup thread stopped"); return 0; @@ -337,6 +345,26 @@ static DWORD WINAPI discovery_thread_proc(LPVOID param) break; } } + /* last_gateway_mac is only ever read/written on this thread, so no + lock is needed here. A momentary gap in gateway resolution + (e.g. the adapter briefly reporting no default gateway) should + not scatter this poll's devices into the "unknown network" + bucket; keep attributing them to the last confirmed network + instead. A genuine change refreshes the ISP lookup immediately + rather than waiting for the hourly timer. */ + if (gateway_mac[0] == '\0' && state->last_gateway_mac_set) { + gateway_mac = state->last_gateway_mac; + } else if (gateway_mac[0] != '\0' && + (!state->last_gateway_mac_set || + strcmp(state->last_gateway_mac, gateway_mac) != 0)) { + LP_INFO("gateway changed to %s; refreshing ISP lookup", gateway_mac); + snprintf(state->last_gateway_mac, sizeof(state->last_gateway_mac), "%s", + gateway_mac); + state->last_gateway_mac_set = true; + if (state->isp_refresh_event != NULL) { + SetEvent(state->isp_refresh_event); + } + } /* Applies previously learned identity to notifications/logging too, not just the map, so a transient DNS miss doesn't hide a known name. */ for (size_t i = 0; i < event_count; ++i) { @@ -881,6 +909,10 @@ static LRESULT CALLBACK tray_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM CloseHandle(state->update_stop_event); state->update_stop_event = NULL; } + if (state->isp_refresh_event != NULL) { + CloseHandle(state->isp_refresh_event); + state->isp_refresh_event = NULL; + } lp_win32_device_store_close(state->device_store); state->device_store = NULL; if (can_delete_lock) { @@ -980,6 +1012,10 @@ int lp_tray_run(const lp_sampler_config_t *config, bool use_bits, unsigned inter if (g_tray.update_stop_event == NULL) { LP_WARN("failed to create update-check stop event"); } + g_tray.isp_refresh_event = CreateEventA(NULL, FALSE, FALSE, NULL); + if (g_tray.isp_refresh_event == NULL) { + LP_WARN("failed to create ISP refresh event"); + } g_tray.thread = CreateThread(NULL, 0, sampler_thread_proc, &g_tray, 0, NULL); if (g_tray.thread == NULL) { From d32c06437bb7c9410bde229e8823ced3fbb69e72 Mon Sep 17 00:00:00 2001 From: Wissem BOUSSETTA Date: Thu, 10 Sep 2026 17:46:51 +0200 Subject: [PATCH 3/6] Fix proxy-ARP MAC collisions and add device detail panel --- src/platform/win32/discovery_win32.c | 54 ++++++- src/ui/network_map_win32.c | 217 ++++++++++++++++++++++----- 2 files changed, 228 insertions(+), 43 deletions(-) diff --git a/src/platform/win32/discovery_win32.c b/src/platform/win32/discovery_win32.c index b551688..89b1871 100644 --- a/src/platform/win32/discovery_win32.c +++ b/src/platform/win32/discovery_win32.c @@ -169,6 +169,53 @@ static long find_neighbor_mac(const lp_neighbor_list_t *list, const char *mac) return -1; } +/* Avoids re-adding the same address twice within one snapshot pass. */ +static long find_neighbor_ip(const lp_neighbor_list_t *list, const char *ip) +{ + if (ip == NULL || ip[0] == '\0') { + return -1; + } + for (size_t i = 0; i < list->count; ++i) { + if (strcmp(list->items[i].ip, ip) == 0) { + return (long)i; + } + } + return -1; +} + +/* Some public/isolated Wi-Fi networks proxy-ARP: the access point answers for + every client with its own MAC, so distinct devices end up sharing one MAC + in our neighbor table. Treating that shared MAC as a stable identity would + merge those devices into one entry (or hide all but one), so any MAC seen + on more than one IP is blanked out and those neighbors fall back to + IP-based identity instead. */ +static void clear_ambiguous_macs(lp_neighbor_list_t *list) +{ + for (size_t i = 0; i < list->count; ++i) { + if (list->items[i].mac[0] == '\0') { + continue; + } + size_t sharing_count = 1; + for (size_t j = i + 1; j < list->count; ++j) { + if (strcmp(list->items[j].mac, list->items[i].mac) == 0) { + ++sharing_count; + } + } + if (sharing_count <= 1) { + continue; + } + LP_DEBUG("clearing ambiguous MAC %s shared by %llu addresses (likely proxy ARP)", + list->items[i].mac, (unsigned long long)sharing_count); + char ambiguous_mac[LP_MAC_STR_MAX]; + snprintf(ambiguous_mac, sizeof(ambiguous_mac), "%s", list->items[i].mac); + for (size_t j = i; j < list->count; ++j) { + if (strcmp(list->items[j].mac, ambiguous_mac) == 0) { + list->items[j].mac[0] = '\0'; + } + } + } +} + /* Matches active results against passive entries without duplicating devices. */ static long find_neighbor_index(const lp_neighbor_list_t *list, const char *ip, const char *mac) @@ -403,14 +450,14 @@ lp_status_t lp_net_neighbor_snapshot(lp_neighbor_list_t *out) continue; } + if (find_neighbor_ip(out, ip) >= 0) { + continue; + } lp_neighbor_t *neighbor = &out->items[out->count]; memset(neighbor, 0, sizeof(*neighbor)); snprintf(neighbor->ip, sizeof(neighbor->ip), "%s", ip); format_mac(row->PhysicalAddress, row->PhysicalAddressLength, neighbor->mac, sizeof(neighbor->mac)); - if (find_neighbor_mac(out, neighbor->mac) >= 0) { - continue; - } neighbor->connection_type = connection_type_for_interface(&row->InterfaceLuid); neighbor->hostname[0] = '\0'; if (winsock_ready) { @@ -428,6 +475,7 @@ lp_status_t lp_net_neighbor_snapshot(lp_neighbor_list_t *out) if (active_discovered > 0) { LP_DEBUG("active discovery added %llu neighbor(s)", (unsigned long long)active_discovered); } + clear_ambiguous_macs(out); return LP_OK; } diff --git a/src/ui/network_map_win32.c b/src/ui/network_map_win32.c index 23adfa2..af6daf0 100644 --- a/src/ui/network_map_win32.c +++ b/src/ui/network_map_win32.c @@ -4,7 +4,7 @@ #include #define LP_MAP_WIDTH 380 -#define LP_MAP_HEIGHT 650 +#define LP_MAP_HEIGHT 740 #define LP_MAP_MAX_VISIBLE_DEVICES 6 #define LP_MAP_ANIMATION_TIMER 2 #define LP_MAP_ANIMATION_STEP_MS 10 @@ -12,6 +12,7 @@ #define LP_ISP_DETAIL_LINE_MAX 300 #define LP_ISP_DETAIL_LINES_MAX 3 #define LP_ISP_DETAIL_LINE_HEIGHT 20 +#define LP_DEVICE_DETAIL_LINES_MAX 3 typedef struct { lp_neighbor_list_t neighbors; @@ -24,6 +25,8 @@ typedef struct { int target_y; DWORD animation_started_at; bool show_isp_details; + bool has_selected_device; + char selected_device_ip[LP_IP_STR_MAX]; } lp_network_map_state_t; /* Reads the taskbar theme setting used to keep the map consistent with the tray. */ @@ -120,7 +123,7 @@ static const wchar_t *device_icon_glyph(const lp_neighbor_t *neighbor) case LP_DEVICE_ROUTER: return L"\xE968"; default: - return L"\xE7B3"; + return L"\xE897"; /* "Help" glyph: reads as an unknown-device marker rather than an eye. */ } } @@ -258,6 +261,82 @@ static size_t format_isp_detail_lines( return count; } +/* Orders "This PC" (when known) ahead of discovered neighbors, capped for the grid. */ +static size_t collect_visible_devices(const lp_network_map_state_t *state, + lp_neighbor_t *out_items, bool *out_is_local, + size_t max_count) +{ + size_t count = 0; + if (count < max_count && + (state->networks.local_hostname[0] != '\0' || state->networks.local_ip[0] != '\0')) { + lp_neighbor_t local_device = {0}; + snprintf(local_device.hostname, sizeof(local_device.hostname), "%s", + state->networks.local_hostname); + snprintf(local_device.ip, sizeof(local_device.ip), "%s", state->networks.local_ip); + local_device.device_type = LP_DEVICE_DESKTOP; + local_device.device_confidence = 100; + local_device.connection_type = state->networks.local_connection_type; + out_items[count] = local_device; + out_is_local[count] = true; + ++count; + } + for (size_t i = 0; i < state->neighbors.count && count < max_count; ++i) { + if (is_device_neighbor(state, &state->neighbors.items[i])) { + out_items[count] = state->neighbors.items[i]; + out_is_local[count] = false; + ++count; + } + } + return count; +} + +/* Computes the same grid position paint_map and click hit-testing must agree on. */ +static void device_node_center(size_t visible_index, size_t visible_count, int center_x, + int isp_extra_height, int *out_x, int *out_y) +{ + const int columns = visible_count > 1 ? 2 : 1; + const int column = (int)visible_index % columns; + const int row = (int)visible_index / columns; + *out_x = columns == 1 ? center_x : 100 + column * 180; + *out_y = 285 + row * 112 + isp_extra_height; +} + +/* Builds up to LP_DEVICE_DETAIL_LINES_MAX lines describing everything known about a device. */ +static size_t format_device_detail_lines( + const lp_neighbor_t *neighbor, bool is_local, + char lines[LP_DEVICE_DETAIL_LINES_MAX][LP_ISP_DETAIL_LINE_MAX]) +{ + size_t count = 0; + if (!is_local && neighbor->vendor[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", neighbor->vendor); + } + if (!is_local && count < LP_DEVICE_DETAIL_LINES_MAX) { + if (neighbor->mac[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", neighbor->mac); + } else { + /* Public/isolated Wi-Fi often proxy-ARPs, so the real MAC is never visible. */ + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, + "MAC hidden by this network"); + } + } + if (count < LP_DEVICE_DETAIL_LINES_MAX) { + const char *connection = neighbor->connection_type == LP_CONNECTION_WIFI ? "Wi-Fi" + : neighbor->connection_type == LP_CONNECTION_ETHERNET + ? "Ethernet" + : "Unknown connection"; + if (neighbor->ip[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s \xB7 %s", neighbor->ip, + connection); + } else { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", connection); + } + } + if (count == 0) { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "No additional details yet"); + } + return count; +} + /* Paints a snapshot of gateway and device state using the current theme. */ static void paint_map(HWND window, HDC dc) { @@ -282,18 +361,12 @@ static void paint_map(HWND window, HDC dc) RECT close_rect = {client.right - 42, 8, client.right - 8, 42}; draw_centered_text(dc, state->label_font, muted, "x", close_rect); - lp_neighbor_t local_device = {0}; - bool has_local_device = false; - if (state->networks.local_hostname[0] != '\0' || state->networks.local_ip[0] != '\0') { - snprintf(local_device.hostname, sizeof(local_device.hostname), "%s", - state->networks.local_hostname); - snprintf(local_device.ip, sizeof(local_device.ip), "%s", state->networks.local_ip); - local_device.device_type = LP_DEVICE_DESKTOP; - local_device.device_confidence = 100; - local_device.connection_type = state->networks.local_connection_type; - has_local_device = true; - } - size_t device_count = has_local_device ? 1 : 0; + lp_neighbor_t visible_items[LP_MAP_MAX_VISIBLE_DEVICES]; + bool visible_is_local[LP_MAP_MAX_VISIBLE_DEVICES] = {0}; + size_t device_count = state->networks.local_hostname[0] != '\0' || + state->networks.local_ip[0] != '\0' + ? 1 + : 0; for (size_t i = 0; i < state->neighbors.count; ++i) { if (is_device_neighbor(state, &state->neighbors.items[i])) { ++device_count; @@ -301,6 +374,8 @@ static void paint_map(HWND window, HDC dc) } const size_t visible_count = device_count < LP_MAP_MAX_VISIBLE_DEVICES ? device_count : LP_MAP_MAX_VISIBLE_DEVICES; + const size_t visible_actual = + collect_visible_devices(state, visible_items, visible_is_local, visible_count); const int center_x = client.right / 2; char isp_detail_lines[LP_ISP_DETAIL_LINES_MAX][LP_ISP_DETAIL_LINE_MAX]; size_t isp_detail_line_count = 0; @@ -350,48 +425,62 @@ static void paint_map(HWND window, HDC dc) gateway_y, 170); size_t visible_index = 0; - if (has_local_device && visible_index < visible_count) { - const int columns = visible_count > 1 ? 2 : 1; - const int column = (int)visible_index % columns; - const int row = (int)visible_index / columns; - const int device_x = columns == 1 ? center_x : 100 + column * 180; - const int device_y = 285 + row * 112 + isp_extra_height; - draw_device_node(dc, state->label_font, state->icon_font, device_fill, line, text, muted, - "This PC", &local_device, device_x, device_y); - ++visible_index; - } - for (size_t i = 0; i < state->neighbors.count && visible_index < visible_count; ++i) { - const lp_neighbor_t *neighbor = &state->neighbors.items[i]; - if (!is_device_neighbor(state, neighbor)) { - continue; - } - const int columns = visible_count > 1 ? 2 : 1; - const int column = (int)visible_index % columns; - const int row = (int)visible_index / columns; - const int device_x = columns == 1 ? center_x : 100 + column * 180; - const int device_y = 285 + row * 112 + isp_extra_height; + lp_neighbor_t selected_device = {0}; + bool selected_is_local = false; + bool has_selected_match = false; + for (; visible_index < visible_actual; ++visible_index) { + int device_x, device_y; + device_node_center(visible_index, visible_actual, center_x, isp_extra_height, &device_x, + &device_y); + const lp_neighbor_t *neighbor = &visible_items[visible_index]; char label[LP_HOSTNAME_MAX]; - if (neighbor->label[0] != '\0') { + if (visible_is_local[visible_index]) { + snprintf(label, sizeof(label), "This PC"); + } else if (neighbor->label[0] != '\0') { snprintf(label, sizeof(label), "%s", neighbor->label); } else if (neighbor->hostname[0] != '\0') { display_hostname(neighbor->hostname, label, sizeof(label)); if (label[0] == '\0') { - snprintf(label, sizeof(label), "Device %llu", - (unsigned long long)visible_index + 1); + snprintf(label, sizeof(label), "Device %llu", (unsigned long long)visible_index + 1); } } else { snprintf(label, sizeof(label), "Device %llu", (unsigned long long)visible_index + 1); } draw_device_node(dc, state->label_font, state->icon_font, device_fill, line, text, muted, - label, neighbor, device_x, device_y); - ++visible_index; + label, neighbor, device_x, device_y); + if (state->has_selected_device && neighbor->ip[0] != '\0' && + strcmp(neighbor->ip, state->selected_device_ip) == 0) { + selected_device = *neighbor; + selected_is_local = visible_is_local[visible_index]; + has_selected_match = true; + } } - if (visible_count == 0) { + if (visible_actual == 0) { RECT empty = {40, 285 + isp_extra_height, client.right - 40, 345 + isp_extra_height}; draw_centered_text(dc, state->label_font, muted, "Waiting for nearby devices...", empty); } + if (has_selected_match) { + char detail_lines[LP_DEVICE_DETAIL_LINES_MAX][LP_ISP_DETAIL_LINE_MAX]; + const size_t detail_line_count = + format_device_detail_lines(&selected_device, selected_is_local, detail_lines); + const int panel_top = client.bottom - 130; + RECT divider = {30, panel_top, client.right - 30, panel_top}; + HPEN divider_pen = CreatePen(PS_SOLID, 1, line); + HPEN old_divider_pen = (HPEN)SelectObject(dc, divider_pen); + MoveToEx(dc, divider.left, divider.top, NULL); + LineTo(dc, divider.right, divider.top); + SelectObject(dc, old_divider_pen); + DeleteObject(divider_pen); + for (size_t i = 0; i < detail_line_count; ++i) { + RECT detail_rect = {30, panel_top + 12 + (int)i * LP_ISP_DETAIL_LINE_HEIGHT, + client.right - 30, + panel_top + 12 + (int)(i + 1) * LP_ISP_DETAIL_LINE_HEIGHT}; + draw_centered_text(dc, state->label_font, muted, detail_lines[i], detail_rect); + } + } + HBRUSH old_brush = (HBRUSH)SelectObject(dc, GetStockObject(NULL_BRUSH)); HPEN frame_pen = CreatePen(PS_SOLID, 1, line); old_pen = (HPEN)SelectObject(dc, frame_pen); @@ -484,6 +573,54 @@ static LRESULT CALLBACK network_map_wndproc(HWND window, UINT message, WPARAM wp state->show_isp_details = !state->show_isp_details; InvalidateRect(window, NULL, TRUE); } + return 0; + } + { + lp_network_map_state_t *state = + (lp_network_map_state_t *)GetWindowLongPtrA(window, GWLP_USERDATA); + if (state == NULL) { + return 0; + } + int isp_extra_height = 0; + if (state->show_isp_details) { + char isp_detail_lines[LP_ISP_DETAIL_LINES_MAX][LP_ISP_DETAIL_LINE_MAX]; + const size_t isp_detail_line_count = + format_isp_detail_lines(&state->networks.isp_info, isp_detail_lines); + isp_extra_height = (int)isp_detail_line_count * LP_ISP_DETAIL_LINE_HEIGHT + 8; + } + size_t device_count = state->networks.local_hostname[0] != '\0' || + state->networks.local_ip[0] != '\0' + ? 1 + : 0; + for (size_t i = 0; i < state->neighbors.count; ++i) { + if (is_device_neighbor(state, &state->neighbors.items[i])) { + ++device_count; + } + } + const size_t visible_count = + device_count < LP_MAP_MAX_VISIBLE_DEVICES ? device_count : LP_MAP_MAX_VISIBLE_DEVICES; + lp_neighbor_t visible_items[LP_MAP_MAX_VISIBLE_DEVICES]; + bool visible_is_local[LP_MAP_MAX_VISIBLE_DEVICES] = {0}; + const size_t visible_actual = + collect_visible_devices(state, visible_items, visible_is_local, visible_count); + for (size_t i = 0; i < visible_actual; ++i) { + int device_x, device_y; + device_node_center(i, visible_actual, center_x, isp_extra_height, &device_x, + &device_y); + if (x >= device_x - 75 && x <= device_x + 75 && y >= device_y - 45 && + y <= device_y + 45) { + if (state->has_selected_device && + strcmp(state->selected_device_ip, visible_items[i].ip) == 0) { + state->has_selected_device = false; + } else { + snprintf(state->selected_device_ip, sizeof(state->selected_device_ip), "%s", + visible_items[i].ip); + state->has_selected_device = true; + } + InvalidateRect(window, NULL, TRUE); + break; + } + } } return 0; } From 247bf1cb76e6d896c33cbbb8f618d2b74a1af817 Mon Sep 17 00:00:00 2001 From: Wissem BOUSSETTA Date: Fri, 11 Sep 2026 17:22:36 +0100 Subject: [PATCH 4/6] Identify private randomized Wi-Fi clients --- src/platform/win32/device_store_win32.c | 3 ++- src/platform/win32/discovery_win32.c | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/platform/win32/device_store_win32.c b/src/platform/win32/device_store_win32.c index 589d429..d2eee64 100644 --- a/src/platform/win32/device_store_win32.c +++ b/src/platform/win32/device_store_win32.c @@ -601,7 +601,8 @@ void lp_win32_device_store_observe(void *opaque, const lp_network_context_t *net if (device->first_seen == 0) device->first_seen = now_seconds(); copy_text(device->hostname, sizeof(device->hostname), neighbor->hostname); copy_text(device->vendor, sizeof(device->vendor), neighbor->vendor); - if (!device->has_device_type && neighbor->device_type != LP_DEVICE_UNKNOWN) { + if (!device->has_device_type && neighbor->device_type != LP_DEVICE_UNKNOWN && + neighbor->device_confidence >= 80) { device->device_type = neighbor->device_type; device->has_device_type = true; } diff --git a/src/platform/win32/discovery_win32.c b/src/platform/win32/discovery_win32.c index 89b1871..3f9b7ee 100644 --- a/src/platform/win32/discovery_win32.c +++ b/src/platform/win32/discovery_win32.c @@ -44,6 +44,15 @@ static void format_mac(const UCHAR *address, ULONG length, char *out, size_t out address[3], address[4], address[5]); } +static bool is_locally_administered_mac(const char *mac) +{ + unsigned first_octet = 0; + if (mac == NULL || sscanf(mac, "%2x", &first_octet) != 1) { + return false; + } + return (first_octet & 0x02u) != 0 && (first_octet & 0x01u) == 0; +} + /* Excludes broadcast, multicast, and unspecified addresses from inventory. */ static bool is_unicast_address(const SOCKADDR_INET *address) { @@ -135,6 +144,12 @@ static void classify_neighbor(lp_neighbor_t *neighbor) { classify_identity(neighbor->hostname, neighbor->vendor, sizeof(neighbor->vendor), &neighbor->device_type, &neighbor->device_confidence); + if (neighbor->device_type == LP_DEVICE_UNKNOWN && + is_locally_administered_mac(neighbor->mac)) { + snprintf(neighbor->vendor, sizeof(neighbor->vendor), "Private Wi-Fi MAC"); + neighbor->device_type = LP_DEVICE_MOBILE; + neighbor->device_confidence = 35; + } } /* Maps the Windows adapter media type to the portable connection enum. */ From 529f0a1f3193ff8f50380d8f9e1c2ab35448cbfe Mon Sep 17 00:00:00 2001 From: Wissem BOUSSETTA Date: Fri, 11 Sep 2026 17:30:38 +0100 Subject: [PATCH 5/6] Add mDNS fallback for local device names --- src/platform/win32/discovery_win32.c | 182 +++++++++++++++++++++++++++ src/ui/network_map_win32.c | 2 + 2 files changed, 184 insertions(+) diff --git a/src/platform/win32/discovery_win32.c b/src/platform/win32/discovery_win32.c index 3f9b7ee..f96cb75 100644 --- a/src/platform/win32/discovery_win32.c +++ b/src/platform/win32/discovery_win32.c @@ -16,6 +16,9 @@ #define LP_ACTIVE_SCAN_MAX_PREFIX 24 #define LP_ACTIVE_SCAN_MAX_SUBNETS 8 #define LP_ACTIVE_SCAN_MAX_PROBES 32 +#define LP_MDNS_PORT 5353 +#define LP_MDNS_TIMEOUT_MS 250 +#define LP_DNS_PACKET_MAX 1500 static uint32_t g_active_probe_offset; @@ -67,6 +70,182 @@ static bool is_unicast_address(const SOCKADDR_INET *address) return false; } +static uint16_t dns_read_u16(const unsigned char *packet, size_t offset) +{ + return (uint16_t)((packet[offset] << 8) | packet[offset + 1]); +} + +static uint32_t dns_read_u32(const unsigned char *packet, size_t offset) +{ + return ((uint32_t)packet[offset] << 24) | ((uint32_t)packet[offset + 1] << 16) | + ((uint32_t)packet[offset + 2] << 8) | (uint32_t)packet[offset + 3]; +} + +static bool dns_skip_name(const unsigned char *packet, size_t packet_len, size_t *offset) +{ + for (int guard = 0; guard < 128 && *offset < packet_len; ++guard) { + const unsigned char label_len = packet[*offset]; + if (label_len == 0) { + ++(*offset); + return true; + } + if ((label_len & 0xC0u) == 0xC0u) { + if (*offset + 1 >= packet_len) return false; + *offset += 2; + return true; + } + if ((label_len & 0xC0u) != 0 || *offset + 1 + label_len > packet_len) { + return false; + } + *offset += 1 + label_len; + } + return false; +} + +static bool dns_read_name(const unsigned char *packet, size_t packet_len, size_t *offset, + char *out, size_t out_cap) +{ + size_t cursor = *offset; + size_t out_len = 0; + bool jumped = false; + out[0] = '\0'; + for (int guard = 0; guard < 128 && cursor < packet_len; ++guard) { + const unsigned char label_len = packet[cursor]; + if (label_len == 0) { + if (!jumped) *offset = cursor + 1; + return out_len > 0; + } + if ((label_len & 0xC0u) == 0xC0u) { + if (cursor + 1 >= packet_len) return false; + const size_t target = (size_t)(((label_len & 0x3Fu) << 8) | packet[cursor + 1]); + if (!jumped) *offset = cursor + 2; + cursor = target; + jumped = true; + continue; + } + if ((label_len & 0xC0u) != 0 || cursor + 1 + label_len > packet_len) { + return false; + } + if (out_len > 0 && out_len + 1 < out_cap) { + out[out_len++] = '.'; + } + for (size_t i = 0; i < label_len && out_len + 1 < out_cap; ++i) { + out[out_len++] = (char)packet[cursor + 1 + i]; + } + out[out_len] = '\0'; + cursor += 1 + label_len; + } + return false; +} + +static bool dns_write_qname(unsigned char *packet, size_t packet_cap, size_t *offset, + const char *name) +{ + const char *label = name; + while (*label != '\0') { + const char *dot = strchr(label, '.'); + const size_t label_len = dot != NULL ? (size_t)(dot - label) : strlen(label); + if (label_len == 0 || label_len > 63 || *offset + 1 + label_len >= packet_cap) { + return false; + } + packet[(*offset)++] = (unsigned char)label_len; + memcpy(packet + *offset, label, label_len); + *offset += label_len; + if (dot == NULL) break; + label = dot + 1; + } + if (*offset >= packet_cap) return false; + packet[(*offset)++] = 0; + return true; +} + +static bool resolve_mdns_hostname_ipv4(const SOCKADDR_INET *address, char *out, size_t out_cap) +{ + unsigned char query[512] = {0}; + char reverse_name[64]; + const uint32_t host_address = ntohl(address->Ipv4.sin_addr.S_un.S_addr); + snprintf(reverse_name, sizeof(reverse_name), "%u.%u.%u.%u.in-addr.arpa", + (unsigned)(host_address & 0xffu), (unsigned)((host_address >> 8) & 0xffu), + (unsigned)((host_address >> 16) & 0xffu), (unsigned)((host_address >> 24) & 0xffu)); + + query[5] = 1; /* QDCOUNT */ + size_t query_len = 12; + if (!dns_write_qname(query, sizeof(query), &query_len, reverse_name) || + query_len + 4 > sizeof(query)) { + return false; + } + query[query_len++] = 0; + query[query_len++] = 12; /* PTR */ + query[query_len++] = 0; + query[query_len++] = 1; /* IN */ + + SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock == INVALID_SOCKET) { + return false; + } + DWORD timeout = LP_MDNS_TIMEOUT_MS; + (void)setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&timeout, sizeof(timeout)); + unsigned char ttl = 255; + (void)setsockopt(sock, IPPROTO_IP, IP_MULTICAST_TTL, (const char *)&ttl, sizeof(ttl)); + + SOCKADDR_IN destination; + memset(&destination, 0, sizeof(destination)); + destination.sin_family = AF_INET; + destination.sin_port = htons(LP_MDNS_PORT); + if (InetPtonA(AF_INET, "224.0.0.251", &destination.sin_addr) != 1) { + closesocket(sock); + return false; + } + const int sent = sendto(sock, (const char *)query, (int)query_len, 0, + (const SOCKADDR *)&destination, sizeof(destination)); + if (sent == SOCKET_ERROR) { + closesocket(sock); + return false; + } + + unsigned char response[LP_DNS_PACKET_MAX]; + const int received = recvfrom(sock, (char *)response, sizeof(response), 0, NULL, NULL); + closesocket(sock); + if (received < 12) { + return false; + } + + const size_t response_len = (size_t)received; + const uint16_t qdcount = dns_read_u16(response, 4); + const uint16_t ancount = dns_read_u16(response, 6); + const uint16_t nscount = dns_read_u16(response, 8); + const uint16_t arcount = dns_read_u16(response, 10); + size_t offset = 12; + for (uint16_t i = 0; i < qdcount; ++i) { + if (!dns_skip_name(response, response_len, &offset) || offset + 4 > response_len) { + return false; + } + offset += 4; + } + const uint32_t record_count = (uint32_t)ancount + (uint32_t)nscount + (uint32_t)arcount; + for (uint32_t i = 0; i < record_count; ++i) { + if (!dns_skip_name(response, response_len, &offset) || offset + 10 > response_len) { + return false; + } + const uint16_t type = dns_read_u16(response, offset); + (void)dns_read_u16(response, offset + 2); + (void)dns_read_u32(response, offset + 4); + const uint16_t rdlength = dns_read_u16(response, offset + 8); + offset += 10; + if (offset + rdlength > response_len) { + return false; + } + if (type == 12) { + size_t rdata_offset = offset; + if (dns_read_name(response, response_len, &rdata_offset, out, out_cap)) { + return true; + } + } + offset += rdlength; + } + return false; +} + /* Performs best-effort reverse DNS without making discovery fail on lookup errors. */ static void resolve_hostname(const SOCKADDR_INET *address, char *out, size_t out_cap) { @@ -77,6 +256,9 @@ static void resolve_hostname(const SOCKADDR_INET *address, char *out, size_t out NI_NAMEREQD) != 0) { out[0] = '\0'; } + if (out[0] == '\0' && address->si_family == AF_INET) { + (void)resolve_mdns_hostname_ipv4(address, out, out_cap); + } } /* Performs a case-insensitive substring check for hostname classification rules. */ diff --git a/src/ui/network_map_win32.c b/src/ui/network_map_win32.c index af6daf0..a140a1a 100644 --- a/src/ui/network_map_win32.c +++ b/src/ui/network_map_win32.c @@ -443,6 +443,8 @@ static void paint_map(HWND window, HDC dc) if (label[0] == '\0') { snprintf(label, sizeof(label), "Device %llu", (unsigned long long)visible_index + 1); } + } else if (neighbor->vendor[0] != '\0') { + snprintf(label, sizeof(label), "%s", neighbor->vendor); } else { snprintf(label, sizeof(label), "Device %llu", (unsigned long long)visible_index + 1); } From bee350b5204e8a3e78e96a3495b6b507bb88828a Mon Sep 17 00:00:00 2001 From: Wissem BOUSSETTA Date: Fri, 11 Sep 2026 17:41:40 +0100 Subject: [PATCH 6/6] Refresh selected device identity on card click --- include/linkpulse/discovery.h | 3 + src/platform/win32/discovery_win32.c | 50 +++++++++-- src/ui/network_map_win32.c | 10 +++ src/ui/network_map_win32.h | 2 + src/ui/tray_win32.c | 126 +++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 8 deletions(-) diff --git a/include/linkpulse/discovery.h b/include/linkpulse/discovery.h index 367deeb..91cfc32 100644 --- a/include/linkpulse/discovery.h +++ b/include/linkpulse/discovery.h @@ -77,6 +77,9 @@ typedef struct { typedef lp_status_t (*lp_net_neighbor_snapshot_fn)(lp_neighbor_list_t *out); typedef lp_status_t (*lp_net_local_networks_fn)(lp_local_network_list_t *out); +/* Refreshes best-effort name/type metadata for one already-known neighbor. */ +lp_status_t lp_net_refresh_neighbor_identity(lp_neighbor_t *neighbor); + typedef struct { lp_net_neighbor_snapshot_fn snapshot_fn; lp_net_local_networks_fn local_networks_fn; diff --git a/src/platform/win32/discovery_win32.c b/src/platform/win32/discovery_win32.c index f96cb75..2f24568 100644 --- a/src/platform/win32/discovery_win32.c +++ b/src/platform/win32/discovery_win32.c @@ -159,7 +159,8 @@ static bool dns_write_qname(unsigned char *packet, size_t packet_cap, size_t *of return true; } -static bool resolve_mdns_hostname_ipv4(const SOCKADDR_INET *address, char *out, size_t out_cap) +static bool resolve_mdns_hostname_ipv4(const SOCKADDR_INET *address, bool targeted, char *out, + size_t out_cap) { unsigned char query[512] = {0}; char reverse_name[64]; @@ -192,7 +193,9 @@ static bool resolve_mdns_hostname_ipv4(const SOCKADDR_INET *address, char *out, memset(&destination, 0, sizeof(destination)); destination.sin_family = AF_INET; destination.sin_port = htons(LP_MDNS_PORT); - if (InetPtonA(AF_INET, "224.0.0.251", &destination.sin_addr) != 1) { + if (targeted) { + destination.sin_addr = address->Ipv4.sin_addr; + } else if (InetPtonA(AF_INET, "224.0.0.251", &destination.sin_addr) != 1) { closesocket(sock); return false; } @@ -256,9 +259,6 @@ static void resolve_hostname(const SOCKADDR_INET *address, char *out, size_t out NI_NAMEREQD) != 0) { out[0] = '\0'; } - if (out[0] == '\0' && address->si_family == AF_INET) { - (void)resolve_mdns_hostname_ipv4(address, out, out_cap); - } } /* Performs a case-insensitive substring check for hostname classification rules. */ @@ -334,6 +334,32 @@ static void classify_neighbor(lp_neighbor_t *neighbor) } } +lp_status_t lp_net_refresh_neighbor_identity(lp_neighbor_t *neighbor) +{ + if (neighbor == NULL || neighbor->ip[0] == '\0') { + return LP_ERR_INVALID_ARG; + } + SOCKADDR_INET address; + memset(&address, 0, sizeof(address)); + address.Ipv4.sin_family = AF_INET; + if (InetPtonA(AF_INET, neighbor->ip, &address.Ipv4.sin_addr) != 1) { + return LP_ERR_UNSUPPORTED; + } + + WSADATA winsock_data; + if (WSAStartup(MAKEWORD(2, 2), &winsock_data) != 0) { + return LP_ERR_IO; + } + resolve_hostname(&address, neighbor->hostname, sizeof(neighbor->hostname)); + if (neighbor->hostname[0] == '\0') { + (void)resolve_mdns_hostname_ipv4(&address, true, neighbor->hostname, + sizeof(neighbor->hostname)); + } + classify_neighbor(neighbor); + WSACleanup(); + return LP_OK; +} + /* Maps the Windows adapter media type to the portable connection enum. */ static lp_connection_type_t connection_type_for_interface(const NET_LUID *interface_luid) { @@ -454,6 +480,10 @@ static void append_active_neighbor(lp_neighbor_list_t *out, const char *ip, address.Ipv4.sin_family = AF_INET; InetPtonA(AF_INET, ip, &address.Ipv4.sin_addr); resolve_hostname(&address, neighbor->hostname, sizeof(neighbor->hostname)); + if (neighbor->hostname[0] == '\0') { + (void)resolve_mdns_hostname_ipv4(&address, false, neighbor->hostname, + sizeof(neighbor->hostname)); + } classify_neighbor(neighbor); } @@ -659,19 +689,23 @@ lp_status_t lp_net_neighbor_snapshot(lp_neighbor_list_t *out) neighbor->hostname[0] = '\0'; if (winsock_ready) { resolve_hostname(&row->Address, neighbor->hostname, sizeof(neighbor->hostname)); + if (neighbor->hostname[0] == '\0' && row->Address.si_family == AF_INET) { + (void)resolve_mdns_hostname_ipv4(&row->Address, false, neighbor->hostname, + sizeof(neighbor->hostname)); + } } classify_neighbor(neighbor); ++out->count; } FreeMibTable(table); - if (winsock_ready) { - WSACleanup(); - } const size_t active_discovered = probe_active_subnets(out); if (active_discovered > 0) { LP_DEBUG("active discovery added %llu neighbor(s)", (unsigned long long)active_discovered); } + if (winsock_ready) { + WSACleanup(); + } clear_ambiguous_macs(out); return LP_OK; } diff --git a/src/ui/network_map_win32.c b/src/ui/network_map_win32.c index a140a1a..3bebe59 100644 --- a/src/ui/network_map_win32.c +++ b/src/ui/network_map_win32.c @@ -618,6 +618,16 @@ static LRESULT CALLBACK network_map_wndproc(HWND window, UINT message, WPARAM wp snprintf(state->selected_device_ip, sizeof(state->selected_device_ip), "%s", visible_items[i].ip); state->has_selected_device = true; + HWND owner = GetWindow(window, GW_OWNER); + if (owner != NULL && visible_items[i].ip[0] != '\0') { + COPYDATASTRUCT copy_data; + memset(©_data, 0, sizeof(copy_data)); + copy_data.dwData = LP_NETWORK_MAP_COPYDATA_DEVICE_SELECTED; + copy_data.cbData = (DWORD)strlen(visible_items[i].ip) + 1; + copy_data.lpData = (PVOID)visible_items[i].ip; + (void)SendMessageA(owner, WM_COPYDATA, (WPARAM)window, + (LPARAM)©_data); + } } InvalidateRect(window, NULL, TRUE); break; diff --git a/src/ui/network_map_win32.h b/src/ui/network_map_win32.h index d6cba18..a95c0ff 100644 --- a/src/ui/network_map_win32.h +++ b/src/ui/network_map_win32.h @@ -5,6 +5,8 @@ #include +#define LP_NETWORK_MAP_COPYDATA_DEVICE_SELECTED 1 + /* Creates the hidden-until-shown network map popup. */ HWND lp_network_map_create(HINSTANCE instance, HWND owner); diff --git a/src/ui/tray_win32.c b/src/ui/tray_win32.c index dc581b7..2559b47 100644 --- a/src/ui/tray_win32.c +++ b/src/ui/tray_win32.c @@ -17,6 +17,7 @@ #include #include +#include #include #include @@ -72,6 +73,7 @@ typedef struct { HANDLE update_thread; HANDLE discovery_thread; HANDLE isp_thread; + HANDLE device_refresh_thread; HANDLE download_thread; HANDLE update_stop_event; HANDLE isp_refresh_event; @@ -545,6 +547,110 @@ static void toggle_network_map(lp_tray_state_t *state) lp_network_map_show(state->network_map_hwnd, &neighbors, &networks); } +typedef struct { + lp_tray_state_t *state; + char ip[LP_IP_STR_MAX]; +} lp_device_refresh_request_t; + +static void network_context_from_snapshot(const lp_local_network_list_t *networks, + const lp_isp_info_t *isp, + lp_network_context_t *out) +{ + memset(out, 0, sizeof(*out)); + out->gateway_mac = ""; + out->gateway_hostname = ""; + out->gateway_vendor = ""; + out->isp = isp; + for (size_t i = 0; i < networks->count; ++i) { + if (networks->items[i].gateway_mac[0] != '\0') { + out->gateway_mac = networks->items[i].gateway_mac; + out->gateway_hostname = networks->items[i].gateway_hostname; + out->gateway_vendor = networks->items[i].gateway_vendor; + return; + } + } +} + +static DWORD WINAPI device_refresh_thread_proc(LPVOID param) +{ + lp_device_refresh_request_t *request = (lp_device_refresh_request_t *)param; + lp_tray_state_t *state = request->state; + lp_neighbor_t neighbor = {0}; + lp_local_network_list_t networks = {0}; + lp_isp_info_t isp = {0}; + bool found = false; + bool isp_available = false; + EnterCriticalSection(&state->lock); + for (size_t i = 0; i < state->map_neighbors.count; ++i) { + if (strcmp(state->map_neighbors.items[i].ip, request->ip) == 0) { + neighbor = state->map_neighbors.items[i]; + found = true; + break; + } + } + networks = state->map_networks; + if (state->isp_available) { + isp = state->isp_info; + isp_available = true; + } + LeaveCriticalSection(&state->lock); + if (!found) { + free(request); + return 0; + } + + LP_DEBUG("refreshing selected device identity: ip=%s mac=%s", neighbor.ip, + neighbor.mac[0] != '\0' ? neighbor.mac : "(unknown)"); + const lp_status_t status = lp_net_refresh_neighbor_identity(&neighbor); + if (status != LP_OK) { + LP_DEBUG("selected device identity refresh failed: ip=%s status=%s", neighbor.ip, + lp_status_str(status)); + free(request); + return 0; + } + + EnterCriticalSection(&state->lock); + for (size_t i = 0; i < state->map_neighbors.count; ++i) { + if (strcmp(state->map_neighbors.items[i].ip, neighbor.ip) == 0) { + state->map_neighbors.items[i] = neighbor; + break; + } + } + lp_network_context_t network_context; + network_context_from_snapshot(&networks, isp_available ? &isp : NULL, &network_context); + lp_win32_device_store_observe(state->device_store, &network_context, &neighbor); + LeaveCriticalSection(&state->lock); + PostMessageA(state->hwnd, WM_LP_DISCOVERY_RESULT, 0, 0); + free(request); + return 0; +} + +static void refresh_selected_device_identity(lp_tray_state_t *state, const char *ip) +{ + if (ip == NULL || ip[0] == '\0') { + return; + } + if (state->device_refresh_thread != NULL) { + if (WaitForSingleObject(state->device_refresh_thread, 0) == WAIT_TIMEOUT) { + LP_DEBUG("selected device refresh already running"); + return; + } + CloseHandle(state->device_refresh_thread); + state->device_refresh_thread = NULL; + } + lp_device_refresh_request_t *request = calloc(1, sizeof(*request)); + if (request == NULL) { + return; + } + request->state = state; + snprintf(request->ip, sizeof(request->ip), "%s", ip); + state->device_refresh_thread = CreateThread(NULL, 0, device_refresh_thread_proc, request, 0, NULL); + if (state->device_refresh_thread == NULL) { + free(request); + return; + } +} + /* Builds the dynamic tray menu and dispatches the selected user command. */ static void show_context_menu(lp_tray_state_t *state) { @@ -812,6 +918,15 @@ static LRESULT CALLBACK tray_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lp_network_map_show(state->network_map_hwnd, &neighbors, &networks); } return 0; + case WM_COPYDATA: { + const COPYDATASTRUCT *copy_data = (const COPYDATASTRUCT *)lparam; + if (copy_data != NULL && copy_data->dwData == LP_NETWORK_MAP_COPYDATA_DEVICE_SELECTED && + copy_data->lpData != NULL && copy_data->cbData > 0) { + refresh_selected_device_identity(state, (const char *)copy_data->lpData); + return TRUE; + } + return FALSE; + } case WM_DESTROY: { bool can_delete_lock = true; lp_config_t config_to_save; @@ -905,6 +1020,17 @@ static LRESULT CALLBACK tray_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM CloseHandle(state->download_thread); state->download_thread = NULL; } + if (state->device_refresh_thread != NULL) { + const DWORD wait_result = WaitForSingleObject(state->device_refresh_thread, + LP_UPDATE_THREAD_SHUTDOWN_TIMEOUT_MS); + if (wait_result == WAIT_TIMEOUT) { + LP_WARN("device-refresh thread did not exit within %u ms; continuing shutdown", + (unsigned)LP_UPDATE_THREAD_SHUTDOWN_TIMEOUT_MS); + can_delete_lock = false; + } + CloseHandle(state->device_refresh_thread); + state->device_refresh_thread = NULL; + } if (state->update_stop_event != NULL && can_close_update_stop_event) { CloseHandle(state->update_stop_event); state->update_stop_event = NULL;