diff --git a/CMakeLists.txt b/CMakeLists.txt index 43e3679..0c8f6bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,8 @@ add_library(lp_platform STATIC src/platform/win32/clock_win32.c src/platform/win32/config_win32.c src/platform/win32/discovery_win32.c + src/platform/win32/device_store_win32.c + src/platform/win32/isp_win32.c src/platform/win32/log_win32.c src/platform/win32/net_win32.c src/platform/win32/notification_win32.c @@ -96,7 +98,7 @@ add_library(lp_platform STATIC src/platform/win32/update_win32.c ) target_link_libraries(lp_platform PUBLIC lp_core PRIVATE lp_warnings) -target_link_libraries(lp_platform PUBLIC iphlpapi ole32 runtimeobject winhttp ws2_32) +target_link_libraries(lp_platform PUBLIC iphlpapi ole32 runtimeobject winhttp ws2_32 xmllite shlwapi) # Windows 10/11 API surface (GetIfTable2, GetBestRoute2). target_compile_definitions(lp_platform PUBLIC _WIN32_WINNT=0x0A00 WIN32_LEAN_AND_MEAN NOMINMAX) diff --git a/include/linkpulse/device_store.h b/include/linkpulse/device_store.h new file mode 100644 index 0000000..f2e9aea --- /dev/null +++ b/include/linkpulse/device_store.h @@ -0,0 +1,18 @@ +#ifndef LINKPULSE_DEVICE_STORE_H +#define LINKPULSE_DEVICE_STORE_H + +#include "linkpulse/discovery.h" + +/* 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); + +/* Records observed metadata and persists the store atomically. */ +void lp_win32_device_store_observe(void *store, const lp_neighbor_t *neighbor); + +/* Releases the store and any XML resources owned by it. */ +void lp_win32_device_store_close(void *store); + +#endif /* LINKPULSE_DEVICE_STORE_H */ \ No newline at end of file diff --git a/include/linkpulse/discovery.h b/include/linkpulse/discovery.h index 8b2d03e..367deeb 100644 --- a/include/linkpulse/discovery.h +++ b/include/linkpulse/discovery.h @@ -5,6 +5,7 @@ #include #include +#include "linkpulse/isp.h" #include "linkpulse/status.h" #define LP_MAC_STR_MAX 18 @@ -37,10 +38,14 @@ typedef struct { char ip[LP_IP_STR_MAX]; char mac[LP_MAC_STR_MAX]; char hostname[LP_HOSTNAME_MAX]; + char label[LP_HOSTNAME_MAX]; char vendor[LP_VENDOR_MAX]; + char icon[LP_VENDOR_MAX]; lp_connection_type_t connection_type; lp_device_type_t device_type; uint8_t device_confidence; + bool trusted; + bool has_device_type_override; bool active; } lp_neighbor_t; @@ -66,6 +71,7 @@ typedef struct { char local_hostname[LP_HOSTNAME_MAX]; char local_ip[LP_IP_STR_MAX]; lp_connection_type_t local_connection_type; + lp_isp_info_t isp_info; } lp_local_network_list_t; typedef lp_status_t (*lp_net_neighbor_snapshot_fn)(lp_neighbor_list_t *out); diff --git a/include/linkpulse/isp.h b/include/linkpulse/isp.h new file mode 100644 index 0000000..01aa80e --- /dev/null +++ b/include/linkpulse/isp.h @@ -0,0 +1,35 @@ +#ifndef LINKPULSE_ISP_H +#define LINKPULSE_ISP_H + +#include "linkpulse/status.h" + +#define LP_ISP_IP_MAX 46 +#define LP_ISP_NAME_MAX 128 +#define LP_ISP_ASN_MAX 16 +#define LP_ISP_HOSTNAME_MAX 256 +#define LP_ISP_PLACE_MAX 128 +#define LP_ISP_COUNTRY_MAX 8 +#define LP_ISP_POSTAL_MAX 16 +#define LP_ISP_TIMEZONE_MAX 64 +#define LP_ISP_LOC_MAX 32 + +/* Every field the lookup service can report for the internet-facing connection. */ +typedef struct { + char public_ip[LP_ISP_IP_MAX]; + char isp[LP_ISP_NAME_MAX]; /* organization name, without the leading ASxxxx */ + char asn[LP_ISP_ASN_MAX]; /* e.g. "AS3209" */ + char hostname[LP_ISP_HOSTNAME_MAX]; + char city[LP_ISP_PLACE_MAX]; + char region[LP_ISP_PLACE_MAX]; + char country[LP_ISP_COUNTRY_MAX]; + char postal[LP_ISP_POSTAL_MAX]; + char timezone[LP_ISP_TIMEZONE_MAX]; + char loc[LP_ISP_LOC_MAX]; /* "latitude,longitude" */ +} lp_isp_info_t; + +/* Looks up all available public IP/ISP/location fields via an external HTTPS + lookup service. Populates as many fields as the response provides; missing + fields are left as empty strings. */ +lp_status_t lp_isp_lookup(lp_isp_info_t *out); + +#endif /* LINKPULSE_ISP_H */ diff --git a/src/cli/main.c b/src/cli/main.c index d36f6ed..3293883 100644 --- a/src/cli/main.c +++ b/src/cli/main.c @@ -106,7 +106,7 @@ static int list_interfaces(void) const size_t interface_count = list.count; lp_iface_list_free(&list); - LP_DEBUG("listed %zu network interfaces", interface_count); + LP_DEBUG("listed %llu network interfaces", (unsigned long long)interface_count); return 0; } diff --git a/src/core/log.c b/src/core/log.c index 5ace80d..8c38fdc 100644 --- a/src/core/log.c +++ b/src/core/log.c @@ -22,21 +22,31 @@ static void format_timestamp(char *out, size_t out_cap) } snprintf(out, out_cap, "0000-00-00 00:00:00.000"); + time_t now_seconds; + long milliseconds = 0; +#if defined(__MINGW32__) + /* MinGW-w64's bundled does not reliably declare C11's + timespec_get/TIME_UTC (confirmed on this project's mingw-cross CI + toolchain), so this path falls back to whole-second resolution. */ + now_seconds = time(NULL); +#else struct timespec now; if (timespec_get(&now, TIME_UTC) != TIME_UTC) { return; } + now_seconds = now.tv_sec; + milliseconds = now.tv_nsec / 1000000L; +#endif struct tm local_now; #if defined(_WIN32) - if (localtime_s(&local_now, &now.tv_sec) != 0) { + if (localtime_s(&local_now, &now_seconds) != 0) { #else - if (localtime_r(&now.tv_sec, &local_now) == NULL) { + if (localtime_r(&now_seconds, &local_now) == NULL) { #endif return; } - const long milliseconds = now.tv_nsec / 1000000L; snprintf(out, out_cap, "%04d-%02d-%02d %02d:%02d:%02d.%03ld", local_now.tm_year + 1900, local_now.tm_mon + 1, local_now.tm_mday, local_now.tm_hour, local_now.tm_min, local_now.tm_sec, milliseconds); diff --git a/src/platform/win32/device_store_win32.c b/src/platform/win32/device_store_win32.c new file mode 100644 index 0000000..a2b4442 --- /dev/null +++ b/src/platform/win32/device_store_win32.c @@ -0,0 +1,372 @@ +#define COBJMACROS +#if defined(__MINGW32__) +/* Must precede : mingw-w64's guiddef.h fixes the DEFINE_GUID + expansion (declare-only vs. define) the first time it is pulled in, so + INITGUID has to be set before windows.h, not just before xmllite.h. + mingw-w64's xmllite.h only defines (rather than declares) IID_IXmlReader/ + IID_IXmlWriter when INITGUID is set, and unlike MSVC's uuid.lib, mingw's + libuuid.a does not provide them, which otherwise fails at link time. */ +#define INITGUID +#endif + +#include "linkpulse/device_store.h" +#include "linkpulse/log.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#define LP_DEVICE_STORE_MAX 256 +#define LP_DEVICE_STORE_PATH_MAX MAX_PATH + +typedef struct { + char mac[LP_MAC_STR_MAX]; + char label[LP_HOSTNAME_MAX]; + char hostname[LP_HOSTNAME_MAX]; + char vendor[LP_VENDOR_MAX]; + char icon[LP_VENDOR_MAX]; + lp_device_type_t device_type; + bool has_device_type; + bool trusted; + unsigned long long first_seen; + unsigned long long last_seen; +} lp_device_record_t; + +typedef struct { + char path[LP_DEVICE_STORE_PATH_MAX]; + lp_device_record_t records[LP_DEVICE_STORE_MAX]; + size_t count; +} lp_device_store_t; + +static void copy_text(char *out, size_t cap, const char *value) +{ + if (out == NULL || cap == 0) { + return; + } + snprintf(out, cap, "%s", value != NULL ? value : ""); +} + +static bool utf8_to_wide(const char *value, wchar_t *out, size_t cap) +{ + if (value == NULL || out == NULL || cap == 0) { + return false; + } + const int written = MultiByteToWideChar(CP_UTF8, 0, value, -1, out, (int)cap); + return written > 0; +} + +static bool wide_to_utf8(const WCHAR *value, char *out, size_t cap) +{ + if (value == NULL || out == NULL || cap == 0) { + return false; + } + const int written = WideCharToMultiByte(CP_UTF8, 0, value, -1, out, (int)cap, NULL, NULL); + if (written <= 0) { + out[0] = '\0'; + return false; + } + return true; +} + +static bool store_path(char *out, size_t cap) +{ + WCHAR local_app_data[MAX_PATH]; + const DWORD length = GetEnvironmentVariableW(L"LOCALAPPDATA", local_app_data, + ARRAYSIZE(local_app_data)); + if (length == 0 || length >= ARRAYSIZE(local_app_data)) { + return false; + } + WCHAR directory[MAX_PATH]; + if (swprintf(directory, ARRAYSIZE(directory), L"%ls\\LinkPulse", local_app_data) < 0) { + return false; + } + if (CreateDirectoryW(directory, NULL) == 0 && GetLastError() != ERROR_ALREADY_EXISTS) { + return false; + } + WCHAR path[MAX_PATH]; + if (swprintf(path, ARRAYSIZE(path), L"%ls\\devices.xml", directory) < 0) { + return false; + } + return WideCharToMultiByte(CP_UTF8, 0, path, -1, out, (int)cap, NULL, NULL) > 0; +} + +static lp_device_type_t type_from_text(const char *value) +{ + if (strcmp(value, "laptop") == 0) return LP_DEVICE_LAPTOP; + if (strcmp(value, "mobile") == 0) return LP_DEVICE_MOBILE; + if (strcmp(value, "smartwatch") == 0) return LP_DEVICE_SMARTWATCH; + if (strcmp(value, "printer") == 0) return LP_DEVICE_PRINTER; + if (strcmp(value, "television") == 0) return LP_DEVICE_TELEVISION; + if (strcmp(value, "router") == 0) return LP_DEVICE_ROUTER; + if (strcmp(value, "desktop") == 0) return LP_DEVICE_DESKTOP; + return LP_DEVICE_UNKNOWN; +} + +static const char *type_to_text(lp_device_type_t type) +{ + switch (type) { + case LP_DEVICE_LAPTOP: return "laptop"; + case LP_DEVICE_MOBILE: return "mobile"; + case LP_DEVICE_SMARTWATCH: return "smartwatch"; + case LP_DEVICE_PRINTER: return "printer"; + case LP_DEVICE_TELEVISION: return "television"; + case LP_DEVICE_ROUTER: return "router"; + case LP_DEVICE_DESKTOP: return "desktop"; + default: return "unknown"; + } +} + +static unsigned long long now_seconds(void) +{ + const time_t now = time(NULL); + return now > 0 ? (unsigned long long)now : 0; +} + +static long find_record(const lp_device_store_t *store, const char *mac) +{ + for (size_t i = 0; i < store->count; ++i) { + if (_stricmp(store->records[i].mac, mac) == 0) { + return (long)i; + } + } + return -1; +} + +static void apply_record(const lp_device_record_t *record, lp_neighbor_t *neighbor) +{ + if (record->label[0] != '\0') copy_text(neighbor->label, sizeof(neighbor->label), record->label); + /* Falls back to a previously learned name/vendor when this poll's live + resolution came back empty (e.g. a transient reverse-DNS failure). */ + if (neighbor->hostname[0] == '\0' && record->hostname[0] != '\0') { + copy_text(neighbor->hostname, sizeof(neighbor->hostname), record->hostname); + } + if (neighbor->vendor[0] == '\0' && record->vendor[0] != '\0') { + copy_text(neighbor->vendor, sizeof(neighbor->vendor), record->vendor); + } + if (record->icon[0] != '\0') copy_text(neighbor->icon, sizeof(neighbor->icon), record->icon); + if (record->trusted) neighbor->trusted = true; + if (record->has_device_type) { + neighbor->device_type = record->device_type; + neighbor->has_device_type_override = true; + } +} + +static void read_device_attributes(IXmlReader *reader, lp_device_record_t *record) +{ + if (IXmlReader_MoveToFirstAttribute(reader) != S_OK) { + return; + } + /* Bounded by attribute count, but capped defensively in case a future + schema change or malformed file confuses the attribute cursor. */ + 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, "mac") == 0) copy_text(record->mac, sizeof(record->mac), value_utf8); + else if (strcmp(name_utf8, "label") == 0) copy_text(record->label, sizeof(record->label), value_utf8); + else if (strcmp(name_utf8, "hostname") == 0) copy_text(record->hostname, sizeof(record->hostname), value_utf8); + else if (strcmp(name_utf8, "vendor") == 0) copy_text(record->vendor, sizeof(record->vendor), value_utf8); + else if (strcmp(name_utf8, "icon") == 0) copy_text(record->icon, sizeof(record->icon), value_utf8); + else if (strcmp(name_utf8, "type") == 0) { + record->device_type = type_from_text(value_utf8); + record->has_device_type = record->device_type != LP_DEVICE_UNKNOWN; + } else if (strcmp(name_utf8, "trusted") == 0) { + record->trusted = strcmp(value_utf8, "true") == 0 || strcmp(value_utf8, "1") == 0; + } else if (strcmp(name_utf8, "firstSeen") == 0) { + record->first_seen = _strtoui64(value_utf8, NULL, 10); + } else if (strcmp(name_utf8, "lastSeen") == 0) { + record->last_seen = _strtoui64(value_utf8, NULL, 10); + } + if (IXmlReader_MoveToNextAttribute(reader) != S_OK) { + break; + } + } + (void)IXmlReader_MoveToElement(reader); +} + +static void load_store(lp_device_store_t *store) +{ + const HRESULT com_result = CoInitializeEx(NULL, COINIT_MULTITHREADED); + const bool uninitialize = SUCCEEDED(com_result); + WCHAR path[MAX_PATH]; + if (!utf8_to_wide(store->path, path, ARRAYSIZE(path))) { + if (uninitialize) CoUninitialize(); + return; + } + IStream *stream = NULL; + if (FAILED(SHCreateStreamOnFileEx(path, STGM_READ | STGM_SHARE_DENY_NONE, 0, FALSE, NULL, + &stream))) { + if (uninitialize) CoUninitialize(); + return; + } + IXmlReader *reader = NULL; + if (FAILED(CreateXmlReader(&IID_IXmlReader, (void **)&reader, NULL)) || + FAILED(IXmlReader_SetInput(reader, (IUnknown *)stream))) { + if (reader != NULL) IXmlReader_Release(reader); + IStream_Release(stream); + if (uninitialize) CoUninitialize(); + return; + } + XmlNodeType node_type; + /* Hard-capped rather than an unconditional while(Read()): guarantees + termination even if a reader implementation ever fails to report + end-of-document via node_type, which previously caused the tray to + hang forever (100% CPU, tray icon never created) parsing a real file. */ + for (int guard = 0; guard < 100000; ++guard) { + const HRESULT read_result = IXmlReader_Read(reader, &node_type); + if (read_result == S_FALSE || node_type == XmlNodeType_None) { + break; /* end of document */ + } + if (FAILED(read_result)) { + break; + } + if (node_type != XmlNodeType_Element) continue; + 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; + } + IXmlReader_Release(reader); + IStream_Release(stream); + if (uninitialize) CoUninitialize(); +} + +static void write_attribute(IXmlWriter *writer, const char *name, const char *value) +{ + wchar_t name_wide[64]; + wchar_t value_wide[LP_HOSTNAME_MAX]; + if (utf8_to_wide(name, name_wide, ARRAYSIZE(name_wide)) && + utf8_to_wide(value, value_wide, ARRAYSIZE(value_wide))) { + (void)IXmlWriter_WriteAttributeString(writer, NULL, name_wide, NULL, value_wide); + } +} + +static void save_store(const lp_device_store_t *store) +{ + const HRESULT com_result = CoInitializeEx(NULL, COINIT_MULTITHREADED); + const bool uninitialize = SUCCEEDED(com_result); + char temp_path[LP_DEVICE_STORE_PATH_MAX + 8]; + snprintf(temp_path, sizeof(temp_path), "%s.tmp", store->path); + WCHAR temp_wide[MAX_PATH]; + if (!utf8_to_wide(temp_path, temp_wide, ARRAYSIZE(temp_wide))) { + if (uninitialize) CoUninitialize(); + return; + } + IStream *stream = NULL; + if (FAILED(SHCreateStreamOnFileEx(temp_wide, STGM_CREATE | STGM_WRITE | STGM_SHARE_DENY_NONE, + FILE_ATTRIBUTE_NORMAL, TRUE, NULL, &stream))) { + if (uninitialize) CoUninitialize(); + return; + } + IXmlWriter *writer = NULL; + if (FAILED(CreateXmlWriter(&IID_IXmlWriter, (void **)&writer, NULL)) || + FAILED(IXmlWriter_SetOutput(writer, (IUnknown *)stream))) { + if (writer != NULL) IXmlWriter_Release(writer); + IStream_Release(stream); + DeleteFileA(temp_path); + if (uninitialize) CoUninitialize(); + 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]; + 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); + 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_WriteEndDocument(writer); + (void)IXmlWriter_Flush(writer); + IXmlWriter_Release(writer); + IStream_Release(stream); + if (!MoveFileExA(temp_path, store->path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DeleteFileA(temp_path); + } + if (uninitialize) CoUninitialize(); +} + +int lp_win32_device_store_open(void **store_out) +{ + if (store_out == NULL) return 1; + *store_out = NULL; + lp_device_store_t *store = calloc(1, sizeof(*store)); + if (store == NULL || !store_path(store->path, sizeof(store->path))) { + free(store); + return 1; + } + load_store(store); + *store_out = store; + LP_DEBUG("device store opened: records=%llu", (unsigned long long)store->count); + return 0; +} + +void lp_win32_device_store_apply(void *opaque, 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); +} + +void lp_win32_device_store_observe(void *opaque, 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(); + save_store(store); +} + +void lp_win32_device_store_close(void *opaque) +{ + lp_device_store_t *store = opaque; + if (store == NULL) return; + free(store); +} diff --git a/src/platform/win32/discovery_win32.c b/src/platform/win32/discovery_win32.c index 14e71a6..b551688 100644 --- a/src/platform/win32/discovery_win32.c +++ b/src/platform/win32/discovery_win32.c @@ -1,4 +1,5 @@ #include "linkpulse/discovery.h" +#include "linkpulse/log.h" #include #include @@ -11,18 +12,21 @@ #include #include -/* States that mean "the OS currently believes this neighbour is present" -- - excludes NlnsIncomplete (resolution in progress, not yet confirmed) and - NlnsUnreachable (resolution failed / timed out). */ -/* Filters neighbor-table states that represent a currently usable identity. */ +/* Active probing is bounded to avoid turning discovery into a large LAN scan. */ +#define LP_ACTIVE_SCAN_MAX_PREFIX 24 +#define LP_ACTIVE_SCAN_MAX_SUBNETS 8 +#define LP_ACTIVE_SCAN_MAX_PROBES 32 + +static uint32_t g_active_probe_offset; + +/* Keeps neighbors with recent or active reachability evidence. Stale and + permanent cache entries are excluded because they can outlive a device. */ static bool is_live_state(NL_NEIGHBOR_STATE state) { switch (state) { case NlnsReachable: - case NlnsStale: case NlnsDelay: case NlnsProbe: - case NlnsPermanent: return true; default: return false; @@ -165,6 +169,203 @@ static long find_neighbor_mac(const lp_neighbor_list_t *list, const char *mac) return -1; } +/* 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) +{ + if (mac != NULL && mac[0] != '\0') { + const long by_mac = find_neighbor_mac(list, mac); + if (by_mac >= 0) { + return by_mac; + } + } + 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; +} + +/* Adds one successful ARP response while preserving any richer passive data. */ +static void append_active_neighbor(lp_neighbor_list_t *out, const char *ip, + const char *mac, const NET_LUID *interface_luid) +{ + const long existing = find_neighbor_index(out, ip, mac); + if (existing >= 0 || out->count >= LP_DISCOVERY_MAX_NEIGHBORS) { + return; + } + + lp_neighbor_t *neighbor = &out->items[out->count++]; + memset(neighbor, 0, sizeof(*neighbor)); + snprintf(neighbor->ip, sizeof(neighbor->ip), "%s", ip); + snprintf(neighbor->mac, sizeof(neighbor->mac), "%s", mac); + neighbor->connection_type = connection_type_for_interface(interface_luid); + neighbor->hostname[0] = '\0'; + SOCKADDR_INET address; + memset(&address, 0, sizeof(address)); + address.Ipv4.sin_family = AF_INET; + InetPtonA(AF_INET, ip, &address.Ipv4.sin_addr); + resolve_hostname(&address, neighbor->hostname, sizeof(neighbor->hostname)); + classify_neighbor(neighbor); +} + +typedef struct { + IPAddr destination; + IPAddr source; + UCHAR mac[6]; + ULONG mac_length; + DWORD status; +} lp_arp_probe_t; + +/* Runs one blocking ARP request away from the discovery worker's main loop. */ +static DWORD WINAPI active_probe_thread_proc(LPVOID param) +{ + lp_arp_probe_t *probe = (lp_arp_probe_t *)param; + probe->mac_length = sizeof(probe->mac); + probe->status = SendARP(probe->destination, probe->source, (PULONG)probe->mac, + &probe->mac_length); + return 0; +} + +/* Probes bounded on-link IPv4 networks so idle devices need not be in the ARP cache. */ +static size_t probe_active_subnets(lp_neighbor_list_t *out) +{ + ULONG buffer_size = 0; + const ULONG flags = GAA_FLAG_INCLUDE_PREFIX; + DWORD result = GetAdaptersAddresses(AF_INET, flags, NULL, NULL, &buffer_size); + if (result != ERROR_BUFFER_OVERFLOW || buffer_size == 0) { + return 0; + } + + IP_ADAPTER_ADDRESSES *adapters = + (IP_ADAPTER_ADDRESSES *)HeapAlloc(GetProcessHeap(), 0, buffer_size); + if (adapters == NULL) { + return 0; + } + result = GetAdaptersAddresses(AF_INET, flags, NULL, adapters, &buffer_size); + if (result != NO_ERROR) { + HeapFree(GetProcessHeap(), 0, adapters); + return 0; + } + + uint32_t scanned_networks[LP_ACTIVE_SCAN_MAX_SUBNETS] = {0}; + uint8_t scanned_prefixes[LP_ACTIVE_SCAN_MAX_SUBNETS] = {0}; + size_t scanned_count = 0; + size_t discovered_count = 0; + size_t probes_sent = 0; + + for (IP_ADAPTER_ADDRESSES *adapter = adapters; adapter != NULL; + adapter = adapter->Next) { + if (adapter->OperStatus != IfOperStatusUp || adapter->IfType == IF_TYPE_SOFTWARE_LOOPBACK) { + continue; + } + for (IP_ADAPTER_UNICAST_ADDRESS *unicast = adapter->FirstUnicastAddress; + unicast != NULL; unicast = unicast->Next) { + if (unicast->Address.lpSockaddr == NULL || + unicast->Address.lpSockaddr->sa_family != AF_INET || + unicast->OnLinkPrefixLength < LP_ACTIVE_SCAN_MAX_PREFIX) { + continue; + } + + const SOCKADDR_IN *local = (const SOCKADDR_IN *)unicast->Address.lpSockaddr; + const uint32_t local_host = ntohl(local->sin_addr.S_un.S_addr); + if ((local_host >> 24) == 169 && ((local_host >> 16) & 0xffu) == 254) { + continue; + } + const uint32_t mask = 0xffffffffu << (32u - unicast->OnLinkPrefixLength); + const uint32_t network = local_host & mask; + bool already_scanned = false; + for (size_t i = 0; i < scanned_count; ++i) { + if (scanned_networks[i] == network && + scanned_prefixes[i] == unicast->OnLinkPrefixLength) { + already_scanned = true; + break; + } + } + if (already_scanned || scanned_count >= LP_ACTIVE_SCAN_MAX_SUBNETS) { + continue; + } + scanned_networks[scanned_count] = network; + scanned_prefixes[scanned_count++] = unicast->OnLinkPrefixLength; + + const uint32_t host_count = 1u << (32u - unicast->OnLinkPrefixLength); + if (host_count <= 2) { + continue; + } + const uint32_t usable_count = host_count - 2; + const uint32_t start = g_active_probe_offset % usable_count; + const uint32_t probes_this_subnet = + (usable_count < LP_ACTIVE_SCAN_MAX_PROBES) ? usable_count + : LP_ACTIVE_SCAN_MAX_PROBES; + lp_arp_probe_t probes[LP_ACTIVE_SCAN_MAX_PROBES] = {0}; + HANDLE probe_threads[LP_ACTIVE_SCAN_MAX_PROBES] = {0}; + size_t probe_count = 0; + for (uint32_t probe = 0; probe < probes_this_subnet && + probes_sent < LP_ACTIVE_SCAN_MAX_PROBES && + probe_count < LP_ACTIVE_SCAN_MAX_PROBES; + ++probe) { + const uint32_t host = 1 + ((start + probe) % usable_count); + const uint32_t candidate = network + host; + if (candidate == local_host) { + continue; + } + ++probes_sent; + probes[probe_count].destination = htonl(candidate); + probes[probe_count].source = local->sin_addr.S_un.S_addr; + ++probe_count; + } + + for (size_t i = 0; i < probe_count; ++i) { + probe_threads[i] = CreateThread(NULL, 0, active_probe_thread_proc, &probes[i], 0, + NULL); + if (probe_threads[i] == NULL) { + probes[i].status = ERROR_NOT_ENOUGH_MEMORY; + } + } + if (probe_count > 0) { + (void)WaitForMultipleObjects((DWORD)probe_count, probe_threads, TRUE, 5000); + } + for (size_t i = 0; i < probe_count; ++i) { + if (probe_threads[i] != NULL) { + CloseHandle(probe_threads[i]); + } + if (probes[i].status != NO_ERROR || probes[i].mac_length != sizeof(probes[i].mac)) { + continue; + } + char ip[LP_IP_STR_MAX] = {0}; + IN_ADDR address; + address.S_un.S_addr = probes[i].destination; + if (InetNtopA(AF_INET, &address, ip, sizeof(ip)) == NULL) { + continue; + } + char mac[LP_MAC_STR_MAX]; + format_mac(probes[i].mac, probes[i].mac_length, mac, sizeof(mac)); + const size_t before = out->count; + append_active_neighbor(out, ip, mac, &adapter->Luid); + if (out->count > before) { + ++discovered_count; + } + if (out->count >= LP_DISCOVERY_MAX_NEIGHBORS) { + HeapFree(GetProcessHeap(), 0, adapters); + return discovered_count; + } + } + g_active_probe_offset = (uint32_t)((start + probe_count) % usable_count); + if (probes_sent >= LP_ACTIVE_SCAN_MAX_PROBES) { + HeapFree(GetProcessHeap(), 0, adapters); + return discovered_count; + } + } + } + + HeapFree(GetProcessHeap(), 0, adapters); + return discovered_count; +} + /* Reads the passive ARP/NDP table and enriches live entries with best-effort names. */ lp_status_t lp_net_neighbor_snapshot(lp_neighbor_list_t *out) { @@ -203,6 +404,7 @@ lp_status_t lp_net_neighbor_snapshot(lp_neighbor_list_t *out) } 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)); @@ -222,6 +424,10 @@ lp_status_t lp_net_neighbor_snapshot(lp_neighbor_list_t *out) 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); + } return LP_OK; } diff --git a/src/platform/win32/isp_win32.c b/src/platform/win32/isp_win32.c new file mode 100644 index 0000000..c00cd0f --- /dev/null +++ b/src/platform/win32/isp_win32.c @@ -0,0 +1,169 @@ +#include "linkpulse/isp.h" +#include "linkpulse/log.h" + +#include +#include +#include + +#include +#include + +#define LP_ISP_HOST L"ipinfo.io" +#define LP_ISP_PATH L"/json" + +/* Extracts one bounded string field from the flat JSON lookup response. */ +static lp_status_t extract_field(const char *response, const char *key, char *out, size_t cap) +{ + char needle[32]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + const char *field = strstr(response, needle); + if (field == NULL) { + return LP_ERR_NOT_FOUND; + } + const char *value = strchr(field, ':'); + if (value == NULL) { + return LP_ERR_IO; + } + value = strchr(value, '"'); + if (value == NULL) { + return LP_ERR_IO; + } + ++value; + const char *end = strchr(value, '"'); + if (end == NULL) { + return LP_ERR_IO; + } + const size_t length = (size_t)(end - value); + if (length >= cap) { + return LP_ERR_NO_MEMORY; + } + memcpy(out, value, length); + out[length] = '\0'; + return LP_OK; +} + +/* Strips the leading "ASxxxxx " announcing-AS prefix from an "org" field, + copying the removed prefix into asn_out (without the trailing space). */ +static void split_org_field(const char *org, char *isp_out, size_t isp_cap, char *asn_out, + size_t asn_cap) +{ + asn_out[0] = '\0'; + if (org[0] == 'A' && org[1] == 'S') { + const char *cursor = org + 2; + while (*cursor >= '0' && *cursor <= '9') { + ++cursor; + } + if (cursor != org + 2 && *cursor == ' ') { + const size_t asn_length = (size_t)(cursor - org); + if (asn_length < asn_cap) { + memcpy(asn_out, org, asn_length); + asn_out[asn_length] = '\0'; + } + snprintf(isp_out, isp_cap, "%s", cursor + 1); + return; + } + } + snprintf(isp_out, isp_cap, "%s", org); +} + +static void close_http_handles(HINTERNET request, HINTERNET connection, HINTERNET session) +{ + if (request != NULL) { + WinHttpCloseHandle(request); + } + if (connection != NULL) { + WinHttpCloseHandle(connection); + } + if (session != NULL) { + WinHttpCloseHandle(session); + } +} + +/* Queries every available public IP/ISP/location field over HTTPS; a failed + or missing field is simply left empty rather than failing the whole lookup. */ +lp_status_t lp_isp_lookup(lp_isp_info_t *out) +{ + if (out == NULL) { + return LP_ERR_INVALID_ARG; + } + memset(out, 0, sizeof(*out)); + + HINTERNET session = WinHttpOpen(L"LinkPulse", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (session == NULL) { + return LP_ERR_IO; + } + WinHttpSetTimeouts(session, 5000, 5000, 5000, 5000); + + HINTERNET connection = WinHttpConnect(session, LP_ISP_HOST, INTERNET_DEFAULT_HTTPS_PORT, 0); + if (connection == NULL) { + WinHttpCloseHandle(session); + return LP_ERR_IO; + } + + HINTERNET request = WinHttpOpenRequest(connection, L"GET", LP_ISP_PATH, NULL, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (request == NULL) { + close_http_handles(NULL, connection, session); + return LP_ERR_IO; + } + + const BOOL sent = WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0); + const BOOL received = sent && WinHttpReceiveResponse(request, NULL); + if (!received) { + close_http_handles(request, connection, session); + return LP_ERR_IO; + } + + char response[4096]; + size_t response_length = 0; + for (;;) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(request, &available) || available == 0) { + break; + } + if (available > sizeof(response) - response_length - 1) { + available = (DWORD)(sizeof(response) - response_length - 1); + } + DWORD read = 0; + if (!WinHttpReadData(request, response + response_length, available, &read)) { + close_http_handles(request, connection, session); + return LP_ERR_IO; + } + response_length += read; + if (read == 0 || response_length == sizeof(response) - 1) { + break; + } + } + response[response_length] = '\0'; + close_http_handles(request, connection, session); + + lp_status_t any_field_status = LP_ERR_NOT_FOUND; + char org[LP_ISP_NAME_MAX + LP_ISP_ASN_MAX]; + + if (extract_field(response, "ip", out->public_ip, sizeof(out->public_ip)) == LP_OK) + any_field_status = LP_OK; + if (extract_field(response, "org", org, sizeof(org)) == LP_OK) { + any_field_status = LP_OK; + split_org_field(org, out->isp, sizeof(out->isp), out->asn, sizeof(out->asn)); + } + if (extract_field(response, "hostname", out->hostname, sizeof(out->hostname)) == LP_OK) + any_field_status = LP_OK; + if (extract_field(response, "city", out->city, sizeof(out->city)) == LP_OK) + any_field_status = LP_OK; + if (extract_field(response, "region", out->region, sizeof(out->region)) == LP_OK) + any_field_status = LP_OK; + if (extract_field(response, "country", out->country, sizeof(out->country)) == LP_OK) + any_field_status = LP_OK; + if (extract_field(response, "postal", out->postal, sizeof(out->postal)) == LP_OK) + any_field_status = LP_OK; + if (extract_field(response, "timezone", out->timezone, sizeof(out->timezone)) == LP_OK) + any_field_status = LP_OK; + if (extract_field(response, "loc", out->loc, sizeof(out->loc)) == LP_OK) + any_field_status = LP_OK; + + return any_field_status; +} + diff --git a/src/ui/network_map_win32.c b/src/ui/network_map_win32.c index da49568..23adfa2 100644 --- a/src/ui/network_map_win32.c +++ b/src/ui/network_map_win32.c @@ -9,6 +9,9 @@ #define LP_MAP_ANIMATION_TIMER 2 #define LP_MAP_ANIMATION_STEP_MS 10 #define LP_MAP_ANIMATION_DURATION_MS 180 +#define LP_ISP_DETAIL_LINE_MAX 300 +#define LP_ISP_DETAIL_LINES_MAX 3 +#define LP_ISP_DETAIL_LINE_HEIGHT 20 typedef struct { lp_neighbor_list_t neighbors; @@ -20,6 +23,7 @@ typedef struct { int target_x; int target_y; DWORD animation_started_at; + bool show_isp_details; } lp_network_map_state_t; /* Reads the taskbar theme setting used to keep the map consistent with the tray. */ @@ -86,6 +90,40 @@ static void display_hostname(const char *hostname, char *out, size_t out_cap) out[display_length] = '\0'; } +/* Selects a stable Segoe MDL2 glyph from persisted or inferred device metadata. */ +static const wchar_t *device_icon_glyph(const lp_neighbor_t *neighbor) +{ + if (strcmp(neighbor->icon, "phone") == 0 || strcmp(neighbor->icon, "mobile") == 0) { + return L"\xE8EA"; + } + if (strcmp(neighbor->icon, "watch") == 0 || strcmp(neighbor->icon, "smartwatch") == 0) { + return L"\xE91B"; + } + if (strcmp(neighbor->icon, "printer") == 0) { + return L"\xE749"; + } + if (strcmp(neighbor->icon, "tv") == 0 || strcmp(neighbor->icon, "television") == 0) { + return L"\xE7F4"; + } + switch (neighbor->device_type) { + case LP_DEVICE_LAPTOP: + case LP_DEVICE_DESKTOP: + return L"\xE770"; + case LP_DEVICE_MOBILE: + return L"\xE8EA"; + case LP_DEVICE_SMARTWATCH: + return L"\xE91B"; + case LP_DEVICE_PRINTER: + return L"\xE749"; + case LP_DEVICE_TELEVISION: + return L"\xE7F4"; + case LP_DEVICE_ROUTER: + return L"\xE968"; + default: + return L"\xE7B3"; + } +} + /* Draws a gateway-style rounded node with a label and detail line. */ static void draw_node(HDC dc, HFONT font, COLORREF fill, COLORREF border, COLORREF text_color, const char *label, const char *detail, int center_x, int center_y, @@ -108,7 +146,7 @@ static void draw_node(HDC dc, HFONT font, COLORREF fill, COLORREF border, COLORR draw_centered_text(dc, font, text_color, detail, detail_rect); } -/* Draws a device node with IP, inferred identity, confidence, and link icon. */ +/* Draws a device node with IP and paired device/connection icons. */ static void draw_device_node(HDC dc, HFONT label_font, HFONT icon_font, COLORREF fill, COLORREF border, COLORREF text_color, COLORREF muted, const char *label, const lp_neighbor_t *neighbor, int center_x, @@ -130,50 +168,13 @@ static void draw_device_node(HDC dc, HFONT label_font, HFONT icon_font, COLORREF RECT detail_rect = {node.left + 8, node.top + 22, node.right - 8, node.top + 41}; draw_centered_text(dc, label_font, text_color, neighbor->ip, detail_rect); - char identity[LP_VENDOR_MAX + 32]; - const char *type = "Unknown device"; - switch (neighbor->device_type) { - case LP_DEVICE_LAPTOP: - type = "Laptop"; - break; - case LP_DEVICE_MOBILE: - type = "Mobile phone"; - break; - case LP_DEVICE_SMARTWATCH: - type = "Smartwatch"; - break; - case LP_DEVICE_PRINTER: - type = "Printer"; - break; - case LP_DEVICE_TELEVISION: - type = "Television"; - break; - case LP_DEVICE_ROUTER: - type = "Router"; - break; - case LP_DEVICE_DESKTOP: - type = "Desktop"; - break; - default: - break; - } - if (neighbor->vendor[0] != '\0') { - if (neighbor->device_confidence > 0) { - snprintf(identity, sizeof(identity), "%s - %s - %u%%", type, neighbor->vendor, - (unsigned)neighbor->device_confidence); - } else { - snprintf(identity, sizeof(identity), "%s - %s", type, neighbor->vendor); - } - } else { - if (neighbor->device_confidence > 0) { - snprintf(identity, sizeof(identity), "%s - %u%%", type, - (unsigned)neighbor->device_confidence); - } else { - snprintf(identity, sizeof(identity), "%s", type); - } - } - RECT identity_rect = {node.left + 8, node.top + 40, node.right - 8, node.top + 59}; - draw_centered_text(dc, label_font, muted, identity, identity_rect); + HFONT old_icon_font = (HFONT)SelectObject(dc, icon_font); + SetTextColor(dc, muted); + SetBkMode(dc, TRANSPARENT); + RECT device_icon_rect = {node.left + 8, node.bottom - 28, node.left + 34, node.bottom - 4}; + DrawTextW(dc, device_icon_glyph(neighbor), 1, &device_icon_rect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE); + SelectObject(dc, old_icon_font); const wchar_t *icon = NULL; if (neighbor->connection_type == LP_CONNECTION_WIFI) { @@ -191,9 +192,10 @@ static void draw_device_node(HDC dc, HFONT label_font, HFONT icon_font, COLORREF } } -/* Draws the top-level Internet node shared by all map layouts. */ +/* Draws the top-level Internet node, with the ISP name when it is known. */ static void draw_internet_cloud(HDC dc, HFONT cloud_font, HFONT label_font, COLORREF fill, - COLORREF text_color, int center_x) + COLORREF text_color, COLORREF muted, int center_x, + const char *isp) { HFONT old_font = (HFONT)SelectObject(dc, cloud_font); SetTextColor(dc, fill); @@ -202,8 +204,58 @@ static void draw_internet_cloud(HDC dc, HFONT cloud_font, HFONT label_font, COLO DrawTextW(dc, L"\x2601", 1, &cloud_rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); SelectObject(dc, old_font); - RECT label_rect = {center_x - 60, 46, center_x + 60, 78}; + RECT label_rect = {center_x - 90, 40, center_x + 90, 68}; draw_centered_text(dc, label_font, text_color, "Internet", label_rect); + if (isp != NULL && isp[0] != '\0') { + RECT isp_rect = {center_x - 90, 66, center_x + 90, 90}; + draw_centered_text(dc, label_font, muted, isp, isp_rect); + } +} + +/* Builds up to LP_ISP_DETAIL_LINES_MAX concise lines from all fetched ISP fields. */ +static size_t format_isp_detail_lines( + const lp_isp_info_t *isp, char lines[LP_ISP_DETAIL_LINES_MAX][LP_ISP_DETAIL_LINE_MAX]) +{ + size_t count = 0; + if (isp->asn[0] != '\0' && isp->public_ip[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s \xB7 %s", isp->asn, isp->public_ip); + } else if (isp->public_ip[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", isp->public_ip); + } else if (isp->asn[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", isp->asn); + } + + if (count < LP_ISP_DETAIL_LINES_MAX) { + char place[LP_ISP_DETAIL_LINE_MAX] = ""; + if (isp->city[0] != '\0' && isp->region[0] != '\0' && isp->country[0] != '\0') { + snprintf(place, sizeof(place), "%s, %s, %s", isp->city, isp->region, isp->country); + } else if (isp->city[0] != '\0' && isp->country[0] != '\0') { + snprintf(place, sizeof(place), "%s, %s", isp->city, isp->country); + } else if (isp->city[0] != '\0') { + snprintf(place, sizeof(place), "%s", isp->city); + } else if (isp->country[0] != '\0') { + snprintf(place, sizeof(place), "%s", isp->country); + } + if (place[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", place); + } + } + + if (count < LP_ISP_DETAIL_LINES_MAX) { + if (isp->postal[0] != '\0' && isp->timezone[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s \xB7 %s", isp->postal, + isp->timezone); + } else if (isp->timezone[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", isp->timezone); + } else if (isp->postal[0] != '\0') { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "%s", isp->postal); + } + } + + if (count == 0) { + snprintf(lines[count++], LP_ISP_DETAIL_LINE_MAX, "Fetching ISP information..."); + } + return count; } /* Paints a snapshot of gateway and device state using the current theme. */ @@ -250,8 +302,15 @@ 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 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; + int isp_extra_height = 0; + if (state->show_isp_details) { + 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; + } const int cloud_bottom_y = 104; - const int gateway_y = 165; + const int gateway_y = 165 + isp_extra_height; char gateway[LP_IP_STR_MAX] = "No gateway"; char gateway_label[LP_HOSTNAME_MAX + LP_VENDOR_MAX + 32]; gateway_label[0] = '\0'; @@ -280,7 +339,13 @@ static void paint_map(HWND window, HDC dc) SelectObject(dc, old_pen); DeleteObject(line_pen); - draw_internet_cloud(dc, state->cloud_font, state->label_font, internet_fill, text, center_x); + draw_internet_cloud(dc, state->cloud_font, state->label_font, internet_fill, text, muted, + center_x, state->networks.isp_info.isp); + for (size_t i = 0; i < isp_detail_line_count; ++i) { + RECT detail_rect = {center_x - 120, 92 + (int)i * LP_ISP_DETAIL_LINE_HEIGHT, + center_x + 120, 92 + (int)(i + 1) * LP_ISP_DETAIL_LINE_HEIGHT}; + draw_centered_text(dc, state->label_font, muted, isp_detail_lines[i], detail_rect); + } draw_node(dc, state->label_font, gateway_fill, line, text, gateway_label, gateway, center_x, gateway_y, 170); @@ -290,7 +355,7 @@ static void paint_map(HWND window, HDC dc) 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; + 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; @@ -304,9 +369,11 @@ static void paint_map(HWND window, HDC dc) 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; + const int device_y = 285 + row * 112 + isp_extra_height; char label[LP_HOSTNAME_MAX]; - if (neighbor->hostname[0] != '\0') { + 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", @@ -321,7 +388,7 @@ static void paint_map(HWND window, HDC dc) } if (visible_count == 0) { - RECT empty = {40, 285, client.right - 40, 345}; + 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); } @@ -407,6 +474,16 @@ static LRESULT CALLBACK network_map_wndproc(HWND window, UINT message, WPARAM wp GetClientRect(window, &client); if (x >= client.right - 58 && y <= 58) { ShowWindow(window, SW_HIDE); + return 0; + } + const int center_x = client.right / 2; + if (x >= center_x - 100 && x <= center_x + 100 && y >= 0 && y <= 100) { + lp_network_map_state_t *state = + (lp_network_map_state_t *)GetWindowLongPtrA(window, GWLP_USERDATA); + if (state != NULL) { + state->show_isp_details = !state->show_isp_details; + InvalidateRect(window, NULL, TRUE); + } } return 0; } diff --git a/src/ui/tray_win32.c b/src/ui/tray_win32.c index 27f5464..0f0908b 100644 --- a/src/ui/tray_win32.c +++ b/src/ui/tray_win32.c @@ -3,8 +3,10 @@ #include "linkpulse/autostart.h" #include "linkpulse/clock.h" #include "linkpulse/config.h" +#include "linkpulse/device_store.h" #include "linkpulse/discovery.h" #include "linkpulse/format.h" +#include "linkpulse/isp.h" #include "linkpulse/log.h" #include "linkpulse/net.h" #include "linkpulse/notification.h" @@ -29,6 +31,7 @@ #define LP_UPDATE_THREAD_SHUTDOWN_TIMEOUT_MS 1000 #define LP_UPDATE_CHECK_INTERVAL_MS (6UL * 60UL * 60UL * 1000UL) #define LP_DISCOVERY_INTERVAL_MS (30UL * 1000UL) +#define LP_ISP_LOOKUP_INTERVAL_MS (60UL * 60UL * 1000UL) #define IDM_PAUSE 2001 #define IDM_UNITS_BITS 2002 @@ -56,13 +59,17 @@ typedef struct { volatile LONG use_bits; bool update_available; char update_version[LP_UPDATE_VERSION_MAX]; + bool isp_available; + lp_isp_info_t isp_info; lp_sampler_t sampler; lp_discovery_t discovery; + void *device_store; unsigned interval_ms; HANDLE thread; HANDLE update_thread; HANDLE discovery_thread; + HANDLE isp_thread; HANDLE download_thread; HANDLE update_stop_event; lp_discovery_event_t discovery_events[LP_DISCOVERY_MAX_EVENTS]; @@ -263,6 +270,44 @@ static DWORD WINAPI update_thread_proc(LPVOID param) return 0; } +/* Looks up the public IP/ISP on a long interval; failures are silently retried. */ +static DWORD WINAPI isp_thread_proc(LPVOID param) +{ + lp_tray_state_t *state = (lp_tray_state_t *)param; + LP_DEBUG("ISP lookup thread started"); + for (;;) { + lp_isp_info_t info; + const lp_status_t status = lp_isp_lookup(&info); + if (status == LP_OK) { + LP_INFO("ISP lookup complete: isp=%s asn=%s public_ip=%s hostname=%s city=%s " + "region=%s country=%s postal=%s timezone=%s loc=%s", + info.isp[0] != '\0' ? info.isp : "(unknown)", + info.asn[0] != '\0' ? info.asn : "(unknown)", + info.public_ip[0] != '\0' ? info.public_ip : "(unknown)", + info.hostname[0] != '\0' ? info.hostname : "(unknown)", + info.city[0] != '\0' ? info.city : "(unknown)", + info.region[0] != '\0' ? info.region : "(unknown)", + info.country[0] != '\0' ? info.country : "(unknown)", + info.postal[0] != '\0' ? info.postal : "(unknown)", + info.timezone[0] != '\0' ? info.timezone : "(unknown)", + info.loc[0] != '\0' ? info.loc : "(unknown)"); + EnterCriticalSection(&state->lock); + state->isp_info = info; + state->isp_available = true; + LeaveCriticalSection(&state->lock); + } else { + 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; + } + } + LP_DEBUG("ISP lookup thread stopped"); + return 0; +} + /* Polls passive discovery and publishes map/events data to the UI thread. */ static DWORD WINAPI discovery_thread_proc(LPVOID param) { @@ -274,14 +319,21 @@ 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) { + /* 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); + } size_t active_count = 0; for (size_t i = 0; i < state->discovery.known_count; ++i) { if (state->discovery.known[i].active) { ++active_count; } } - LP_DEBUG("discovery poll complete: active=%zu retained=%zu events=%zu", active_count, - state->discovery.known_count, event_count); + LP_DEBUG("discovery poll complete: active=%llu retained=%llu events=%llu", + (unsigned long long)active_count, + (unsigned long long)state->discovery.known_count, + (unsigned long long)event_count); for (size_t i = 0; i < event_count; ++i) { LP_INFO("discovery event: type=%s ip=%s mac=%s hostname=%s", events[i].type == LP_DISCOVERY_EVENT_JOINED ? "joined" : "left", @@ -300,10 +352,15 @@ static DWORD WINAPI discovery_thread_proc(LPVOID param) state->map_neighbors.count < LP_DISCOVERY_MAX_NEIGHBORS; ++i) { if (state->discovery.known[i].active) { - state->map_neighbors.items[state->map_neighbors.count++] = - state->discovery.known[i]; + 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); + state->map_neighbors.items[state->map_neighbors.count++] = neighbor; } } + if (state->isp_available) { + networks.isp_info = state->isp_info; + } state->map_networks = networks; if (event_count > 0) { memcpy(state->discovery_events, events, event_count * sizeof(events[0])); @@ -397,8 +454,18 @@ static void show_discovery_notifications(lp_tray_state_t *state) for (size_t i = 0; i < event_count; ++i) { const bool joined = events[i].type == LP_DISCOVERY_EVENT_JOINED; - char message[160]; - if (events[i].neighbor.mac[0] != '\0') { + const char *name = events[i].neighbor.label[0] != '\0' ? events[i].neighbor.label + : events[i].neighbor.hostname[0] != '\0' ? events[i].neighbor.hostname + : NULL; + char message[512]; + if (name != NULL && events[i].neighbor.mac[0] != '\0') { + snprintf(message, sizeof(message), "%s\n%s\nIP: %s\nMAC: %s", + joined ? "Connected" : "Disconnected", name, events[i].neighbor.ip, + events[i].neighbor.mac); + } else if (name != NULL) { + snprintf(message, sizeof(message), "%s\n%s\nIP: %s", joined ? "Connected" : "Disconnected", + name, events[i].neighbor.ip); + } else if (events[i].neighbor.mac[0] != '\0') { snprintf(message, sizeof(message), "%s\nIP: %s\nMAC: %s", joined ? "Connected" : "Disconnected", events[i].neighbor.ip, events[i].neighbor.mac); @@ -764,6 +831,23 @@ static LRESULT CALLBACK tray_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM CloseHandle(state->update_thread); state->update_thread = NULL; } + if (state->isp_thread != NULL) { + SetEvent(state->update_stop_event); + const DWORD wait_result = + WaitForSingleObject(state->isp_thread, LP_UPDATE_THREAD_SHUTDOWN_TIMEOUT_MS); + if (wait_result == WAIT_TIMEOUT) { + LP_WARN("ISP lookup thread did not exit within %u ms; continuing shutdown", + (unsigned)LP_UPDATE_THREAD_SHUTDOWN_TIMEOUT_MS); + can_close_update_stop_event = false; + can_delete_lock = false; + } else if (wait_result != WAIT_OBJECT_0) { + LP_WARN("waiting for ISP lookup thread failed during shutdown"); + can_close_update_stop_event = false; + can_delete_lock = false; + } + CloseHandle(state->isp_thread); + state->isp_thread = NULL; + } if (state->download_thread != NULL) { const DWORD wait_result = WaitForSingleObject(state->download_thread, LP_UPDATE_THREAD_SHUTDOWN_TIMEOUT_MS); @@ -780,6 +864,8 @@ static LRESULT CALLBACK tray_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM CloseHandle(state->update_stop_event); state->update_stop_event = NULL; } + lp_win32_device_store_close(state->device_store); + state->device_store = NULL; if (can_delete_lock) { DeleteCriticalSection(&state->lock); } @@ -819,6 +905,9 @@ int lp_tray_run(const lp_sampler_config_t *config, bool use_bits, unsigned inter const lp_discovery_sources_t discovery_sources = {lp_net_neighbor_snapshot, lp_net_local_networks}; lp_discovery_set_sources(&g_tray.discovery, &discovery_sources); + if (lp_win32_device_store_open(&g_tray.device_store) != 0) { + LP_WARN("device metadata store unavailable; continuing without persistence"); + } const HINSTANCE instance = GetModuleHandleA(NULL); WNDCLASSEXA wc; @@ -884,6 +973,7 @@ int lp_tray_run(const lp_sampler_config_t *config, bool use_bits, unsigned inter g_tray.update_thread = CreateThread(NULL, 0, update_thread_proc, &g_tray, 0, NULL); g_tray.discovery_thread = CreateThread(NULL, 0, discovery_thread_proc, &g_tray, 0, NULL); + g_tray.isp_thread = CreateThread(NULL, 0, isp_thread_proc, &g_tray, 0, NULL); } if (g_tray.update_stop_event != NULL && g_tray.update_thread == NULL) { LP_WARN("failed to start the update-check thread"); @@ -891,6 +981,9 @@ int lp_tray_run(const lp_sampler_config_t *config, bool use_bits, unsigned inter if (g_tray.update_stop_event != NULL && g_tray.discovery_thread == NULL) { LP_WARN("failed to start the discovery thread"); } + if (g_tray.update_stop_event != NULL && g_tray.isp_thread == NULL) { + LP_WARN("failed to start the ISP lookup thread"); + } LP_INFO("tray worker threads started"); }