diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7616ad..68fce62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@
本文档格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
+## [Unreleased]
+
+### Added
+
+- 增加 Linux 平台 Provider,支持系统与权限、物理网卡与驱动、Wi-Fi、DHCP、路由、metric、默认路由和邻居表信息收集。
+
## [0.1.0] - 2026-05-22
### Added
@@ -16,3 +22,4 @@
- 增加 CI、标签发布工作流与 Nerdbank.GitVersioning 版本管理。
[0.1.0]: https://github.com/LyCecilion/xidio/releases/tag/v0.1.0
+[Unreleased]: https://github.com/LyCecilion/xidio/compare/v0.1.0...HEAD
diff --git a/README.md b/README.md
index af48ab2..e97542c 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ xidio 的灵感来自 [NanCunChild](https://github.com/NanCunChild) 的 [Marduk]
- **交互式场景问询**:通过 CLI 收集位置、连接方式、问题现象与影响范围,为后续判断补充用户上下文。
- **分层网络信息收集**:收集操作系统、时间、网络接口、IP、DHCP、DNS、默认路由、邻居表和主动探测结果。
- **Windows 深度适配**:利用 Windows 系统能力获取网卡、Wi-Fi、路由、代理与 PPPoE 等诊断信息。
-- **跨平台架构**:通用诊断逻辑位于 `xidio.Core`,平台差异由独立 Provider 隔离;当前已有 Windows 和 macOS Provider,Linux Provider 正在路线图中。
+- **跨平台架构**:通用诊断逻辑位于 `xidio.Core`,平台差异由独立 Provider 隔离;当前已有 Windows、Linux 和 macOS Provider。
- **结构化与可取消**:核心诊断输出结构化数据,长时间任务支持进度报告和 `CancellationToken`。
## 🚀 Quick Start
@@ -53,6 +53,7 @@ xidio/
├── xidio/
│ ├── xidio.CLI/ # 命令行入口与交互式报告
│ ├── xidio.Core/ # 诊断模型、收集、分析与抽象
+│ ├── xidio.Platform.Linux/ # Linux 平台 Provider
│ ├── xidio.Platform.Windows/ # Windows 平台 Provider
│ ├── xidio.Platform.macOS/ # macOS 平台 Provider
│ └── xidio.slnx # .NET 解决方案
@@ -84,6 +85,8 @@ dotnet run --project xidio/xidio.CLI/xidio.CLI.csproj -f net10.0-windows
可以从发行版安装 .NET 10 SDK,也可使用 Nix。原生 SDK 环境下:
+Linux Provider 会按需调用 `ip`、`nmcli` 和 `iw`;建议安装 `iproute2`、NetworkManager 与 `iw`。命令不存在时,xidio 仍会返回 .NET 与 `/sys` 可获取的其他诊断信息。
+
```bash
dotnet restore xidio/xidio.CLI/xidio.CLI.csproj
dotnet build xidio/xidio.CLI/xidio.CLI.csproj -c Debug -f net10.0
@@ -106,7 +109,7 @@ dotnet build xidio/xidio.CLI/xidio.CLI.csproj -c Debug -f net10.0
dotnet run --project xidio/xidio.CLI/xidio.CLI.csproj -f net10.0
```
-Apple Silicon 与 Intel Mac 也可使用已启用 `aarch64-darwin` 和 `x86_64-darwin` 的 `nix develop`。
+Apple Silicon Mac 也可使用已启用 `aarch64-darwin` 的 `nix develop`。当前 nixpkgs 已不再支持 `x86_64-darwin`,Intel Mac 请使用原生 .NET 10 SDK。
## 🗺️ Roadmap
diff --git a/ROADMAP.md b/ROADMAP.md
index ab65422..24ae0b5 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -23,7 +23,7 @@ xidio 需要在 Windows、Linux 和 macOS 上完成“信息收集 → 分层分
### 0.2 跨平台收集补全
-- [ ] 建立并接入 `xidio.Platform.Linux`。
+- [x] 建立并接入 `xidio.Platform.Linux`,收集系统、物理网卡、Wi-Fi、DHCP、路由、metric 与邻居表信息。
- [ ] 对齐 Windows、Linux 和 macOS 的基础系统、网卡、路由、DNS、邻居表、代理与 Wi-Fi 收集能力。
- [ ] 补全 PPPoE 链路、会话、错误码与路由检查。
- [ ] 为外部命令解析与平台特性增加测试。
diff --git a/docs/architecture.md b/docs/architecture.md
index d206507..930c773 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -8,6 +8,7 @@
- `xidio.Core`:xidio 的核心模块,封装了所有诊断、修复和上报逻辑,供 CLI 与 GUI 调用。
- `xidio.CLI`:xidio 的命令行版本,同时也可作为终端内的交互式工具使用。我们将使用 `System.CommandLine` 和 `Spectre.Console`。
+- `xidio.Platform.*`:Windows、Linux 与 macOS 的平台专用信息收集 Provider。
- `xidio.GUI`:xidio 的图形界面版本,基于 Avalonia 框架实现跨平台功能。
### xidio 的调用能力和平台特性
diff --git a/flake.nix b/flake.nix
index 3557606..de823ad 100644
--- a/flake.nix
+++ b/flake.nix
@@ -11,7 +11,6 @@
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
- "x86_64-darwin"
"aarch64-darwin"
];
@@ -32,7 +31,10 @@
]
++ lib.optionals stdenv.isLinux [
clang
+ iproute2
+ iw
lld
+ networkmanager
];
buildInputs = with pkgs; [
diff --git a/xidio/xidio.CLI/Program.cs b/xidio/xidio.CLI/Program.cs
index c069b4f..e62e06e 100644
--- a/xidio/xidio.CLI/Program.cs
+++ b/xidio/xidio.CLI/Program.cs
@@ -5,7 +5,9 @@
using xidio.Core.Models;
#if WINDOWS
using xidio.Platform.Windows;
-#else
+#elif LINUX
+using xidio.Platform.Linux;
+#elif MACOS
using xidio.Platform.macOS;
#endif
@@ -74,10 +76,11 @@ private static IPlatformNetworkDiagnosticsProvider CreatePlatformProvider()
{
#if WINDOWS
return new WindowsPlatformNetworkDiagnosticsProvider();
+#elif LINUX
+ return new LinuxPlatformNetworkDiagnosticsProvider();
+#elif MACOS
+ return new MacPlatformNetworkDiagnosticsProvider();
#else
- if (OperatingSystem.IsMacOS())
- return new MacPlatformNetworkDiagnosticsProvider();
-
return NoopPlatformNetworkDiagnosticsProvider.Instance;
#endif
}
diff --git a/xidio/xidio.CLI/xidio.CLI.csproj b/xidio/xidio.CLI/xidio.CLI.csproj
index d537605..6cb658a 100644
--- a/xidio/xidio.CLI/xidio.CLI.csproj
+++ b/xidio/xidio.CLI/xidio.CLI.csproj
@@ -19,7 +19,19 @@
-
+
+ $(DefineConstants);LINUX
+
+
+
+ $(DefineConstants);MACOS
+
+
+
+
+
+
+
diff --git a/xidio/xidio.Platform.Linux/LinuxPlatformNetworkDiagnosticsProvider.cs b/xidio/xidio.Platform.Linux/LinuxPlatformNetworkDiagnosticsProvider.cs
new file mode 100644
index 0000000..a805c56
--- /dev/null
+++ b/xidio/xidio.Platform.Linux/LinuxPlatformNetworkDiagnosticsProvider.cs
@@ -0,0 +1,813 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using xidio.Core.Abstractions;
+using xidio.Core.Diagnostics;
+using xidio.Core.Models;
+
+namespace xidio.Platform.Linux;
+
+public sealed class LinuxPlatformNetworkDiagnosticsProvider : IPlatformNetworkDiagnosticsProvider
+{
+ private const string SysClassNet = "/sys/class/net";
+ private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
+
+ public async ValueTask GetOperatingSystemInfoAsync(
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var osRelease = await ReadOsReleaseAsync(cancellationToken);
+ osRelease.TryGetValue("PRETTY_NAME", out var description);
+ osRelease.TryGetValue("VERSION_ID", out var version);
+
+ return new OperatingSystemDiagnosticInfo
+ {
+ Family = "Linux",
+ Description = string.IsNullOrWhiteSpace(description)
+ ? RuntimeInformation.OSDescription
+ : description,
+ Version = string.IsNullOrWhiteSpace(version)
+ ? Environment.OSVersion.VersionString
+ : version,
+ Architecture = RuntimeInformation.ProcessArchitecture.ToString(),
+ XidioVersion = XidioVersion.InformationalVersion,
+ IsElevated = GetEffectiveUserId() == 0
+ };
+ }
+
+ public ValueTask> GetPhysicalNetworkInterfaceIdsAsync(
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!Directory.Exists(SysClassNet))
+ return ValueTask.FromResult>(Array.Empty());
+
+ var interfaces = Directory.EnumerateDirectories(SysClassNet)
+ .Where(path => Directory.Exists(Path.Combine(path, "device")))
+ .Select(Path.GetFileName)
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Cast()
+ .ToHashSet(StringComparer.Ordinal);
+
+ return ValueTask.FromResult>(interfaces);
+ }
+
+ public async ValueTask> GetNetworkAdapterDriverInfosAsync(
+ CancellationToken cancellationToken)
+ {
+ var physicalIds = await GetPhysicalNetworkInterfaceIdsAsync(cancellationToken);
+ var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces()
+ .ToDictionary(networkInterface => networkInterface.Name, StringComparer.Ordinal);
+ var result = new List();
+
+ foreach (var interfaceId in physicalIds.Order(StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ networkInterfaces.TryGetValue(interfaceId, out var networkInterface);
+
+ var driverName = GetDriverName(interfaceId);
+ var driverVersion = driverName is null
+ ? null
+ : await ReadTrimmedTextAsync($"/sys/module/{driverName}/version", cancellationToken);
+
+ result.Add(new NetworkAdapterDriverInfo
+ {
+ Name = interfaceId,
+ Description = driverName is null
+ ? networkInterface?.Description ?? interfaceId
+ : $"{networkInterface?.Description ?? interfaceId} ({driverName})",
+ DriverVersion = string.IsNullOrWhiteSpace(driverVersion)
+ ? ""
+ : driverVersion
+ });
+ }
+
+ return result;
+ }
+
+ public async ValueTask GetInterfacePlatformInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ var interfacePath = Path.Combine(SysClassNet, networkInterface.Name);
+ if (!Directory.Exists(interfacePath))
+ return null;
+
+ var flagsText = await ReadTrimmedTextAsync(Path.Combine(interfacePath, "flags"), cancellationToken);
+ var mtuText = await ReadTrimmedTextAsync(Path.Combine(interfacePath, "mtu"), cancellationToken);
+
+ bool? isEnabled = null;
+ if (TryParseHexUInt64(flagsText, out var flags))
+ isEnabled = (flags & 0x1) != 0;
+
+ int? mtu = int.TryParse(mtuText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMtu)
+ ? parsedMtu
+ : null;
+
+ return new InterfacePlatformInfo
+ {
+ IsEnabled = isEnabled,
+ Mtu = mtu
+ };
+ }
+
+ public async ValueTask GetWirelessConnectionInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ var iwOutput = await RunCommandAsync(
+ "iw",
+ ["dev", networkInterface.Name, "link"],
+ cancellationToken);
+ var current = ParseIwLink(iwOutput?.StandardOutput);
+
+ var nmcliOutput = await RunCommandAsync(
+ "nmcli",
+ [
+ "-t",
+ "--escape", "yes",
+ "-f", "IN-USE,SSID,BSSID,SIGNAL,FREQ,CHAN,RATE,SECURITY",
+ "device", "wifi", "list",
+ "ifname", networkInterface.Name,
+ "--rescan", "no"
+ ],
+ cancellationToken);
+ var wifiRows = ParseNmcliWifiRows(nmcliOutput?.StandardOutput);
+ var activeRow = wifiRows.FirstOrDefault(row => row.IsActive);
+
+ if (current is null && activeRow is null)
+ return null;
+
+ var ssid = current?.Ssid ?? activeRow?.Ssid;
+ var bssid = current?.Bssid ?? activeRow?.Bssid;
+ if (string.IsNullOrWhiteSpace(ssid) || string.IsNullOrWhiteSpace(bssid))
+ return null;
+
+ return new WirelessConnectionInfo
+ {
+ Ssid = ssid,
+ Bssid = bssid,
+ SignalQualityPercent = activeRow?.SignalQualityPercent,
+ RssiDbm = current?.RssiDbm,
+ Channel = current?.Channel ?? activeRow?.Channel,
+ FrequencyGhz = current?.FrequencyGhz ?? activeRow?.FrequencyGhz,
+ PhyType = InferPhyType(current?.RateDescription),
+ Authentication = activeRow?.Security,
+ ReceiveRateMbps = current?.ReceiveRateMbps ?? activeRow?.RateMbps,
+ TransmitRateMbps = current?.TransmitRateMbps ?? activeRow?.RateMbps,
+ VisibleNetworks = CreateVisibleNetworks(wifiRows)
+ };
+ }
+
+ public async ValueTask GetInterfaceDhcpInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ var output = await RunCommandAsync(
+ "nmcli",
+ ["-t", "-f", "DHCP4.OPTION,DHCP6.OPTION", "device", "show", networkInterface.Name],
+ cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(output?.StandardOutput))
+ return null;
+
+ long? expiry = null;
+ int? leaseSeconds = null;
+
+ foreach (var line in output.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var separator = line.IndexOf('=');
+ if (separator < 0)
+ continue;
+
+ var name = line[..separator].Trim();
+ var value = line[(separator + 1)..].Trim();
+
+ if (name.EndsWith("expiry", StringComparison.OrdinalIgnoreCase) &&
+ long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedExpiry))
+ {
+ expiry = parsedExpiry;
+ }
+ else if (name.EndsWith("dhcp_lease_time", StringComparison.OrdinalIgnoreCase) &&
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLease))
+ {
+ leaseSeconds = parsedLease;
+ }
+ }
+
+ var leaseExpires = expiry is > 0
+ ? DateTimeOffset.FromUnixTimeSeconds(expiry.Value)
+ : (DateTimeOffset?)null;
+
+ return new InterfaceDhcpInfo
+ {
+ IsEnabled = true,
+ LeaseExpires = leaseExpires,
+ LeaseObtained = leaseExpires is not null && leaseSeconds is > 0
+ ? leaseExpires.Value - TimeSpan.FromSeconds(leaseSeconds.Value)
+ : null
+ };
+ }
+
+ public async ValueTask> GetInterfaceMetricsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ var routes = await GetRoutesAsync(networkInterface, cancellationToken);
+
+ return routes
+ .GroupBy(route => route.AddressFamily)
+ .Select(group => new InterfaceMetricInfo
+ {
+ AddressFamily = group.Key,
+ InterfaceMetric = group
+ .Where(route => route.DestinationPrefix is "0.0.0.0/0" or "::/0")
+ .Select(route => route.TotalMetric)
+ .DefaultIfEmpty(group
+ .Where(route => route.TotalMetric > 0)
+ .Select(route => route.TotalMetric)
+ .DefaultIfEmpty(0)
+ .Min())
+ .Min()
+ })
+ .OrderBy(metric => metric.AddressFamily)
+ .ToList();
+ }
+
+ public async ValueTask> GetRoutesAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ var routes = new List();
+ routes.AddRange(await ReadRoutesAsync(AddressFamily.InterNetwork, networkInterface.Name, cancellationToken));
+ routes.AddRange(await ReadRoutesAsync(AddressFamily.InterNetworkV6, networkInterface.Name, cancellationToken));
+ return routes;
+ }
+
+ public async ValueTask> GetNetworkNeighborsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ var output = await RunCommandAsync("ip", ["-j", "neigh", "show"], cancellationToken);
+ if (string.IsNullOrWhiteSpace(output?.StandardOutput))
+ return Array.Empty();
+
+ try
+ {
+ using var document = JsonDocument.Parse(output.StandardOutput);
+ var result = new List();
+
+ foreach (var item in document.RootElement.EnumerateArray())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!TryGetString(item, "dev", out var device) ||
+ !string.Equals(device, networkInterface.Name, StringComparison.Ordinal) ||
+ !TryGetString(item, "dst", out var destination) ||
+ destination is null)
+ {
+ continue;
+ }
+
+ _ = TryGetString(item, "lladdr", out var linkLayerAddress);
+
+ result.Add(new NetworkNeighborInfo
+ {
+ IpAddress = destination,
+ LinkLayerAddress = linkLayerAddress ?? "",
+ AddressFamily = IPAddress.TryParse(destination, out var address)
+ ? (int)address.AddressFamily
+ : 0,
+ State = GetNeighborState(item)
+ });
+ }
+
+ return result;
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ public async ValueTask> GetDefaultRoutesAsync(
+ CancellationToken cancellationToken)
+ {
+ var result = new List();
+
+ foreach (var family in new[] { AddressFamily.InterNetwork, AddressFamily.InterNetworkV6 })
+ {
+ foreach (var route in await ReadRouteEntriesAsync(family, cancellationToken))
+ {
+ if (!IsDefaultDestination(route.Destination))
+ continue;
+
+ result.Add(new DefaultRouteInfo
+ {
+ DestinationPrefix = family == AddressFamily.InterNetwork ? "0.0.0.0/0" : "::/0",
+ NextHop = route.NextHop,
+ AddressFamily = (int)family,
+ InterfaceIndex = GetInterfaceIndex(route.Device, family),
+ InterfaceAlias = route.Device,
+ RouteMetric = route.Metric,
+ InterfaceMetric = 0
+ });
+ }
+ }
+
+ return result
+ .OrderBy(route => route.AddressFamily)
+ .ThenBy(route => route.TotalMetric)
+ .ThenBy(route => route.InterfaceAlias, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ private static async Task> ReadRoutesAsync(
+ AddressFamily addressFamily,
+ string interfaceName,
+ CancellationToken cancellationToken)
+ {
+ var entries = await ReadRouteEntriesAsync(addressFamily, cancellationToken);
+
+ return entries
+ .Where(entry => string.Equals(entry.Device, interfaceName, StringComparison.Ordinal))
+ .Select(entry => new InterfaceRouteInfo
+ {
+ DestinationPrefix = NormalizeDestination(entry.Destination, addressFamily),
+ NextHop = entry.NextHop,
+ AddressFamily = (int)addressFamily,
+ RouteMetric = entry.Metric,
+ InterfaceMetric = 0
+ })
+ .ToList();
+ }
+
+ private static async Task> ReadRouteEntriesAsync(
+ AddressFamily addressFamily,
+ CancellationToken cancellationToken)
+ {
+ var familyFlag = addressFamily == AddressFamily.InterNetwork ? "-4" : "-6";
+ var output = await RunCommandAsync(
+ "ip",
+ ["-j", familyFlag, "route", "show", "table", "all"],
+ cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(output?.StandardOutput))
+ return Array.Empty();
+
+ try
+ {
+ using var document = JsonDocument.Parse(output.StandardOutput);
+ var result = new List();
+
+ foreach (var item in document.RootElement.EnumerateArray())
+ {
+ if (!TryGetString(item, "dev", out var device) || string.IsNullOrWhiteSpace(device))
+ continue;
+
+ _ = TryGetString(item, "dst", out var destination);
+ _ = TryGetString(item, "gateway", out var gateway);
+
+ result.Add(new RouteEntry(
+ destination ?? "default",
+ gateway ?? "",
+ device,
+ TryGetInt32(item, "metric") ?? 0));
+ }
+
+ return result;
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static IwLinkInfo? ParseIwLink(string? output)
+ {
+ if (string.IsNullOrWhiteSpace(output) ||
+ output.Contains("Not connected", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ string? ssid = null;
+ string? bssid = null;
+ string? rateDescription = null;
+ int? rssi = null;
+ int? frequencyMhz = null;
+ double? receiveRate = null;
+ double? transmitRate = null;
+
+ foreach (var rawLine in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var line = rawLine.Trim();
+
+ if (line.StartsWith("Connected to ", StringComparison.OrdinalIgnoreCase))
+ bssid = line["Connected to ".Length..].Split(' ', 2)[0];
+ else if (line.StartsWith("SSID:", StringComparison.OrdinalIgnoreCase))
+ ssid = line["SSID:".Length..].Trim();
+ else if (line.StartsWith("freq:", StringComparison.OrdinalIgnoreCase))
+ frequencyMhz = ParseFirstInt(line["freq:".Length..]);
+ else if (line.StartsWith("signal:", StringComparison.OrdinalIgnoreCase))
+ rssi = ParseFirstInt(line["signal:".Length..]);
+ else if (line.StartsWith("rx bitrate:", StringComparison.OrdinalIgnoreCase))
+ {
+ rateDescription = line;
+ receiveRate = ParseFirstDouble(line["rx bitrate:".Length..]);
+ }
+ else if (line.StartsWith("tx bitrate:", StringComparison.OrdinalIgnoreCase))
+ {
+ rateDescription ??= line;
+ transmitRate = ParseFirstDouble(line["tx bitrate:".Length..]);
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(ssid) || string.IsNullOrWhiteSpace(bssid))
+ return null;
+
+ return new IwLinkInfo(
+ ssid,
+ bssid,
+ rssi,
+ frequencyMhz is > 0 ? frequencyMhz.Value / 1000d : null,
+ FrequencyMhzToChannel(frequencyMhz),
+ receiveRate,
+ transmitRate,
+ rateDescription);
+ }
+
+ private static IReadOnlyList ParseNmcliWifiRows(string? output)
+ {
+ if (string.IsNullOrWhiteSpace(output))
+ return Array.Empty();
+
+ var result = new List();
+
+ foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var fields = SplitEscapedFields(line);
+ if (fields.Count < 8 || string.IsNullOrWhiteSpace(fields[1]))
+ continue;
+
+ var frequencyMhz = ParseFirstInt(fields[4]);
+ result.Add(new NmcliWifiRow(
+ fields[0] is "*" or "yes",
+ fields[1],
+ fields[2],
+ ParseFirstInt(fields[3]),
+ frequencyMhz is > 0 ? frequencyMhz.Value / 1000d : null,
+ ParseFirstInt(fields[5]) ?? FrequencyMhzToChannel(frequencyMhz),
+ ParseFirstDouble(fields[6]),
+ string.IsNullOrWhiteSpace(fields[7]) ? null : fields[7]));
+ }
+
+ return result;
+ }
+
+ private static List CreateVisibleNetworks(
+ IReadOnlyList rows)
+ {
+ return rows
+ .GroupBy(row => row.Ssid, StringComparer.Ordinal)
+ .Select(group => new VisibleWirelessNetworkInfo
+ {
+ Ssid = group.Key,
+ SignalQualityPercent = group.Max(row => row.SignalQualityPercent),
+ Authentication = group.Select(row => row.Security).FirstOrDefault(value => value is not null),
+ BssidCount = group.Select(row => row.Bssid).Where(value => value.Length > 0).Distinct().Count(),
+ IsConnectable = null
+ })
+ .OrderByDescending(network => network.SignalQualityPercent)
+ .ThenBy(network => network.Ssid, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ private static List SplitEscapedFields(string line)
+ {
+ var result = new List();
+ var current = new List();
+ var escaped = false;
+
+ foreach (var character in line)
+ {
+ if (escaped)
+ {
+ current.Add(character);
+ escaped = false;
+ }
+ else if (character == '\\')
+ {
+ escaped = true;
+ }
+ else if (character == ':')
+ {
+ result.Add(new string(current.ToArray()));
+ current.Clear();
+ }
+ else
+ {
+ current.Add(character);
+ }
+ }
+
+ if (escaped)
+ current.Add('\\');
+
+ result.Add(new string(current.ToArray()));
+ return result;
+ }
+
+ private static string? InferPhyType(string? rateDescription)
+ {
+ if (string.IsNullOrWhiteSpace(rateDescription))
+ return null;
+ if (rateDescription.Contains("EHT", StringComparison.OrdinalIgnoreCase))
+ return "802.11be";
+ if (rateDescription.Contains("HE-", StringComparison.OrdinalIgnoreCase))
+ return "802.11ax";
+ if (rateDescription.Contains("VHT-", StringComparison.OrdinalIgnoreCase))
+ return "802.11ac";
+ if (rateDescription.Contains("MCS", StringComparison.OrdinalIgnoreCase))
+ return "802.11n";
+ return null;
+ }
+
+ private static int? FrequencyMhzToChannel(int? frequencyMhz)
+ {
+ if (frequencyMhz is null or <= 0)
+ return null;
+ if (frequencyMhz == 2484)
+ return 14;
+ if (frequencyMhz is >= 2412 and <= 2472)
+ return (frequencyMhz.Value - 2407) / 5;
+ if (frequencyMhz is >= 5000 and <= 5900)
+ return (frequencyMhz.Value - 5000) / 5;
+ if (frequencyMhz is >= 5955 and <= 7115)
+ return (frequencyMhz.Value - 5950) / 5;
+ return null;
+ }
+
+ private static async Task> ReadOsReleaseAsync(
+ CancellationToken cancellationToken)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+
+ try
+ {
+ foreach (var line in await File.ReadAllLinesAsync("/etc/os-release", cancellationToken))
+ {
+ var separator = line.IndexOf('=');
+ if (separator <= 0)
+ continue;
+
+ result[line[..separator]] = line[(separator + 1)..].Trim().Trim('"');
+ }
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+
+ return result;
+ }
+
+ private static async Task ReadTrimmedTextAsync(
+ string path,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return (await File.ReadAllTextAsync(path, cancellationToken)).Trim();
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static string? GetDriverName(string interfaceName)
+ {
+ try
+ {
+ var driverPath = Path.Combine(SysClassNet, interfaceName, "device", "driver");
+ return Directory.ResolveLinkTarget(driverPath, returnFinalTarget: true)?.Name;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static int GetInterfaceIndex(string interfaceName, AddressFamily addressFamily)
+ {
+ try
+ {
+ var networkInterface = NetworkInterface.GetAllNetworkInterfaces()
+ .FirstOrDefault(item => string.Equals(item.Name, interfaceName, StringComparison.Ordinal));
+ var properties = networkInterface?.GetIPProperties();
+
+ return addressFamily == AddressFamily.InterNetwork
+ ? properties?.GetIPv4Properties()?.Index ?? 0
+ : properties?.GetIPv6Properties()?.Index ?? 0;
+ }
+ catch (NetworkInformationException)
+ {
+ return 0;
+ }
+ }
+
+ private static async Task RunCommandAsync(
+ string fileName,
+ IReadOnlyList arguments,
+ CancellationToken cancellationToken)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(CommandTimeout);
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }
+ };
+
+ process.StartInfo.Environment["LC_ALL"] = "C";
+ foreach (var argument in arguments)
+ process.StartInfo.ArgumentList.Add(argument);
+
+ try
+ {
+ process.Start();
+ var outputTask = process.StandardOutput.ReadToEndAsync(timeout.Token);
+ var errorTask = process.StandardError.ReadToEndAsync(timeout.Token);
+
+ await process.WaitForExitAsync(timeout.Token);
+ var standardOutput = await outputTask;
+ var standardError = await errorTask;
+
+ return process.ExitCode == 0
+ ? new CommandResult(standardOutput, standardError)
+ : null;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ TryKill(process);
+ return null;
+ }
+ catch (OperationCanceledException)
+ {
+ TryKill(process);
+ throw;
+ }
+ catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or IOException)
+ {
+ return null;
+ }
+ }
+
+ private static void TryKill(Process process)
+ {
+ try
+ {
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ }
+
+ private static bool TryGetString(JsonElement element, string propertyName, out string? value)
+ {
+ value = null;
+ if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String)
+ return false;
+
+ value = property.GetString();
+ return value is not null;
+ }
+
+ private static int? TryGetInt32(JsonElement element, string propertyName)
+ {
+ return element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value)
+ ? value
+ : null;
+ }
+
+ private static string GetNeighborState(JsonElement element)
+ {
+ if (!element.TryGetProperty("state", out var state))
+ return "";
+ if (state.ValueKind == JsonValueKind.String)
+ return state.GetString() ?? "";
+ if (state.ValueKind == JsonValueKind.Array)
+ return string.Join(",", state.EnumerateArray().Select(value => value.GetString()));
+ return "";
+ }
+
+ private static bool IsDefaultDestination(string destination)
+ {
+ return string.IsNullOrWhiteSpace(destination) ||
+ destination is "default" or "0.0.0.0/0" or "::/0";
+ }
+
+ private static string NormalizeDestination(string destination, AddressFamily addressFamily)
+ {
+ if (IsDefaultDestination(destination))
+ return addressFamily == AddressFamily.InterNetwork ? "0.0.0.0/0" : "::/0";
+
+ if (IPAddress.TryParse(destination, out var address))
+ return $"{destination}/{(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128)}";
+
+ return destination;
+ }
+
+ private static bool TryParseHexUInt64(string? value, out ulong result)
+ {
+ result = 0;
+ if (string.IsNullOrWhiteSpace(value))
+ return false;
+
+ return ulong.TryParse(
+ value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value[2..] : value,
+ NumberStyles.HexNumber,
+ CultureInfo.InvariantCulture,
+ out result);
+ }
+
+ private static int? ParseFirstInt(string value)
+ {
+ var token = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ return int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)
+ ? result
+ : null;
+ }
+
+ private static double? ParseFirstDouble(string value)
+ {
+ var token = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ return double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)
+ ? result
+ : null;
+ }
+
+ [DllImport("libc")]
+ private static extern uint geteuid();
+
+ private static uint GetEffectiveUserId()
+ {
+ try
+ {
+ return geteuid();
+ }
+ catch (DllNotFoundException)
+ {
+ return uint.MaxValue;
+ }
+ }
+
+ private sealed record CommandResult(string StandardOutput, string StandardError);
+
+ private sealed record RouteEntry(string Destination, string NextHop, string Device, int Metric);
+
+ private sealed record IwLinkInfo(
+ string Ssid,
+ string Bssid,
+ int? RssiDbm,
+ double? FrequencyGhz,
+ int? Channel,
+ double? ReceiveRateMbps,
+ double? TransmitRateMbps,
+ string? RateDescription);
+
+ private sealed record NmcliWifiRow(
+ bool IsActive,
+ string Ssid,
+ string Bssid,
+ int? SignalQualityPercent,
+ double? FrequencyGhz,
+ int? Channel,
+ double? RateMbps,
+ string? Security);
+}
diff --git a/xidio/xidio.Platform.Linux/xidio.Platform.Linux.csproj b/xidio/xidio.Platform.Linux/xidio.Platform.Linux.csproj
new file mode 100644
index 0000000..a5e4516
--- /dev/null
+++ b/xidio/xidio.Platform.Linux/xidio.Platform.Linux.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/xidio/xidio.slnx b/xidio/xidio.slnx
index 1df6d62..4013310 100644
--- a/xidio/xidio.slnx
+++ b/xidio/xidio.slnx
@@ -1,6 +1,7 @@
+