diff --git a/Cargo.lock b/Cargo.lock
index dd34e67..b7a4cf2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -47,6 +47,137 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "atk"
version = "0.18.2"
@@ -142,6 +273,19 @@ dependencies = [
"objc2",
]
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
[[package]]
name = "brotli"
version = "8.0.2"
@@ -322,6 +466,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -697,6 +850,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -714,6 +894,37 @@ dependencies = [
"typeid",
]
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -851,6 +1062,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1236,6 +1460,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
[[package]]
name = "hex"
version = "0.4.3"
@@ -1727,6 +1957,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "litemap"
version = "0.8.1"
@@ -2073,6 +2309,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "pango"
version = "0.18.3"
@@ -2098,6 +2344,12 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2320,6 +2572,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2352,6 +2615,20 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2695,6 +2972,19 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2998,6 +3288,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -3410,6 +3710,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"windows 0.62.2",
+ "zbus",
]
[[package]]
@@ -3512,6 +3813,19 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "tendril"
version = "0.4.3"
@@ -3799,9 +4113,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
+ "tracing-attributes",
"tracing-core",
]
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -3851,6 +4177,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -4734,6 +5071,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
[[package]]
name = "winnow"
@@ -4936,6 +5276,67 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 0.7.15",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
+dependencies = [
+ "serde",
+ "winnow 0.7.15",
+ "zvariant",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.48"
@@ -5015,3 +5416,43 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 0.7.15",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "winnow 0.7.15",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 0aa471f..e8b1377 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,5 +18,8 @@ tracing = { version = "0.1.41", default-features = false, features = ["std", "lo
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62.2", features = ["Networking_Connectivity"] }
+[target.'cfg(target_os = "linux")'.dependencies]
+zbus = "=5.14.0"
+
[build-dependencies]
tauri-plugin = { version = "2.5.1", features = ["build"] }
diff --git a/README.md b/README.md
index a950e43..923f853 100644
--- a/README.md
+++ b/README.md
@@ -17,11 +17,12 @@ decisions.
* Detect connection type (WiFi, Ethernet, Cellular)
* Query metered and constrained status for network policy decisions
* Check internet reachability
- * Cross-platform support (Windows, iOS, Android)
+ * Cross-platform support (Windows, Linux, iOS, Android)
| Platform | Supported |
| -------- | --------- |
| Windows | Yes |
+| Linux | Yes |
| macOS | Planned |
| Android | Planned |
| iOS | Planned |
@@ -68,6 +69,12 @@ Run Rust tests only:
cargo test --workspace --lib
```
+### Manual Linux scenario testing
+
+See [Linux Connectivity Manual Testing](docs/linux-connectivity-manual-testing.md)
+for WSL2, VirtualBox, NetworkManager, ModemManager, metered, constrained, and
+transport-type test scenarios.
+
## Install
_This plugin requires a Rust version of at least **1.94.0**_
@@ -180,11 +187,12 @@ The `connectionStatus()` function returns a `ConnectionStatus` object:
#### Platform mapping
-| Field | Windows | iOS | Android |
-| ---------------- | ----------------------------------------------------------------------------------- | --------------------------- | ---------------------------------- |
-| `metered` | `NetworkCostType` Unknown/Fixed/Variable | `NWPath.isExpensive` | absence of `NOT_METERED` |
-| `constrained` | `ConstrainedInternetAccess`, data-limit, roaming, or background data restrictions | `NWPath.isConstrained` | Data Saver / `RESTRICT_BACKGROUND` |
-| `connectionType` | WWAN/WLAN/IANA interface type | `NWInterface.InterfaceType` | `TRANSPORT_*` capabilities |
+| Field | Windows | Linux | iOS | Android |
+| ---------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------- | ---------------------------------- |
+| `connected` | `InternetAccess` or `ConstrainedInternetAccess` | NetworkManager `FULL`/`PORTAL` or up default route fallback | `NWPath.status` satisfied | active network with internet capability |
+| `metered` | `NetworkCostType` Unknown/Fixed/Variable | NetworkManager primary device `Metered` | `NWPath.isExpensive` | absence of `NOT_METERED` |
+| `constrained` | `ConstrainedInternetAccess`, data-limit, roaming, or background data restrictions | NetworkManager portal/metered or cellular roaming; fallback defaults to `false` | `NWPath.isConstrained` | Data Saver / `RESTRICT_BACKGROUND` |
+| `connectionType` | WWAN/WLAN/IANA interface type | NetworkManager device type or sysfs fallback | `NWInterface.InterfaceType` | `TRANSPORT_*` capabilities |
## Development Standards
diff --git a/docs/linux-connectivity-manual-testing.md b/docs/linux-connectivity-manual-testing.md
new file mode 100644
index 0000000..0ac3f5e
--- /dev/null
+++ b/docs/linux-connectivity-manual-testing.md
@@ -0,0 +1,880 @@
+# Linux Connectivity Manual Testing
+
+Manual Linux scenarios for exercising `src/platform/linux.rs`.
+
+The Linux implementation has two runtime paths:
+
+ * NetworkManager over system D-Bus. This is the preferred path when
+ `org.freedesktop.NetworkManager` owns its bus name. For cellular devices,
+ this path may also query ModemManager for roaming state.
+ * Passive fallback using `/proc/net/route` and `/sys/class/net`. This path is
+ used when the system bus is unavailable, NetworkManager is not running, or a
+ NetworkManager query fails.
+
+## Reference Links
+
+| Item | Link |
+| ---- | ---- |
+| Tauri Linux prerequisites | |
+| VirtualBox networking manual | |
+| NetworkManager `nmcli` manual | |
+| NetworkManager config manual | |
+| NetworkManager D-Bus types | |
+| ModemManager 3GPP D-Bus interface | |
+| WebKitGTK environment variables | |
+
+## Scenario Coverage
+
+All scenarios below were manually tested except the ModemManager scenarios.
+
+| Scenario | Environment | Status | Expected result |
+| -------- | ----------- | ------ | --------------- |
+| Passive fallback connected | WSL2 or VM without NetworkManager | Tested | `connected: true`, `ethernet` |
+| Passive fallback disconnected | WSL2 or VM without default route | Tested | disconnected |
+| NetworkManager full connectivity | VirtualBox NAT or bridged adapter | Tested | `connected: true`, `ethernet` |
+| NetworkManager metered | VirtualBox NAT or bridged adapter | Tested | `metered: true`, `constrained: true` |
+| NetworkManager disabled | VirtualBox VM | Tested | disconnected |
+| Virtual cable disconnected | VirtualBox VM | Tested | disconnected |
+| Local-only `none` or `limited` | VirtualBox host-only adapter | Tested | disconnected |
+| Captive portal | NetworkManager fake portal check | Tested | `connected: true`, `constrained: true` |
+| Connectivity `unknown` fallback | NetworkManager config override | Tested | falls back to `State` |
+| Wi-Fi unmetered and metered | Physical Wi-Fi or USB Wi-Fi pass-through | Tested | `connectionType: "wifi"` |
+| Unknown transport | VM or uncommon primary interface | Tested | `connectionType: "unknown"` |
+| Cellular modem | ModemManager and WWAN hardware | Not tested | `connectionType: "cellular"` |
+| Cellular roaming | ModemManager and roaming SIM/network | Not tested | `constrained: true` |
+
+VirtualBox NAT, bridged, host-only, and internal networks normally appear in the
+guest as Ethernet. Use physical hardware or USB pass-through for Wi-Fi and WWAN
+transport tests.
+
+On Linux, `metered: true` always implies `constrained: true` because constrained
+is derived as:
+
+```text
+constrained = network_manager_connectivity_is_portal
+ || network_manager_device_is_metered
+ || modem_manager_reports_roaming
+```
+
+The passive fallback always reports `metered: false` and `constrained: false`.
+
+## Base Test Setup
+
+Use one checkout of this repository in each Linux environment.
+
+```sh
+git clone https://github.com/silvermine/tauri-plugin-connectivity.git
+cd tauri-plugin-connectivity
+npm install
+npm run build
+```
+
+Install the Linux system packages from the official Tauri prerequisites page for
+the distribution under test. For Ubuntu or Debian based guests:
+
+```sh
+sudo apt update
+sudo apt install -y \
+ build-essential \
+ curl \
+ file \
+ libayatana-appindicator3-dev \
+ librsvg2-dev \
+ libssl-dev \
+ libwebkit2gtk-4.1-dev \
+ libxdo-dev \
+ network-manager \
+ pkg-config \
+ wget
+```
+
+Install Rust if the VM does not already have the toolchain:
+
+```sh
+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
+. "$HOME/.cargo/env"
+rustup show
+```
+
+Run the automated checks once before starting manual scenario work:
+
+```sh
+npm test
+```
+
+Run the example app:
+
+```sh
+cd examples/tauri-app
+npm install
+npm run dev
+```
+
+### Lubuntu Blank Window Workaround
+
+If the Tauri window opens but the page is blank on Lubuntu, set
+`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Tauri uses WebKitGTK on Linux, and this
+forces WebKitGTK to disable accelerated compositing for that process.
+
+Test it for the current shell first:
+
+```sh
+cd examples/tauri-app
+WEBKIT_DISABLE_COMPOSITING_MODE=1 npm run dev
+```
+
+If that fixes the blank page, add it globally on the test machine:
+
+```sh
+echo WEBKIT_DISABLE_COMPOSITING_MODE=1 | sudo tee -a /etc/environment
+```
+
+Then log out and back in, or reboot, and verify:
+
+```sh
+printenv WEBKIT_DISABLE_COMPOSITING_MODE
+```
+
+Use the global setting only for the Linux test environment. If another WebKitGTK
+rendering issue still produces a blank page, also try this one-command variant:
+
+```sh
+WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run dev
+```
+
+For each manual scenario, press `Refresh status` in the example app and record
+the `Raw response`.
+
+The plugin reads NetworkManager's cached `Connectivity` property. Run
+`nmcli networking connectivity check` before refreshing the app when the scenario
+changes connectivity.
+
+## Useful Observation Commands
+
+Run these commands before and after changing network state.
+
+```sh
+nmcli general status
+nmcli networking connectivity
+nmcli connection show --active
+ip route
+cat /proc/net/route
+ls -l /sys/class/net
+```
+
+For the active NetworkManager profile and device:
+
+```sh
+ACTIVE="$(nmcli -t -f NAME,DEVICE connection show --active | \
+ awk -F: '$2 != "lo" { print; exit }')"
+PROFILE="${ACTIVE%%:*}"
+IFACE="${ACTIVE##*:}"
+DEVICE_PATH="$(nmcli -g GENERAL.DBUS-PATH device show "$IFACE")"
+
+printf 'profile=%s\niface=%s\ndevice_path=%s\n' \
+ "$PROFILE" "$IFACE" "$DEVICE_PATH"
+
+nmcli -f GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION \
+ device show "$IFACE"
+
+nmcli -g connection.metered connection show "$PROFILE"
+
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ /org/freedesktop/NetworkManager \
+ org.freedesktop.NetworkManager \
+ Connectivity
+
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ /org/freedesktop/NetworkManager \
+ org.freedesktop.NetworkManager \
+ State
+
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ "$DEVICE_PATH" \
+ org.freedesktop.NetworkManager.Device \
+ DeviceType
+
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ "$DEVICE_PATH" \
+ org.freedesktop.NetworkManager.Device \
+ Metered
+```
+
+Important enum values used by the plugin:
+
+| Source | Value | Meaning in plugin |
+| ------ | ----- | ----------------- |
+| NetworkManager `Connectivity` | `4` | Connected |
+| NetworkManager `Connectivity` | `2` | Connected and constrained |
+| NetworkManager `Connectivity` | `1`, `3` | Disconnected |
+| NetworkManager `Connectivity` | `0` | Fall back to `State` |
+| NetworkManager `State` | `70` | Connected if connectivity is unknown |
+| NetworkManager `DeviceType` | `1` | Ethernet |
+| NetworkManager `DeviceType` | `2` | Wi-Fi |
+| NetworkManager `DeviceType` | `8` | Cellular modem |
+| NetworkManager `Metered` | `1`, `3` | Metered |
+| NetworkManager `Metered` | `0`, `2`, `4` | Not metered |
+| ModemManager `RegistrationState` | `5` | Roaming |
+
+## WSL2 Fallback Scenarios
+
+Install Ubuntu or another WSL distribution:
+
+```powershell
+wsl --install -d Ubuntu
+wsl --set-version Ubuntu 2
+```
+
+Inside WSL2, verify the fallback path:
+
+```sh
+systemctl is-active NetworkManager || true
+busctl --system list | grep NetworkManager || true
+ip route
+cat /proc/net/route
+```
+
+Expected connected fallback response when a non-loopback default route exists:
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "ethernet"
+}
+```
+
+To test fallback disconnected, delete the default route temporarily:
+
+```sh
+DEFAULT_ROUTE="$(ip route show default | head -n 1)"
+sudo ip route del default
+ip route
+```
+
+Expected response:
+
+```json
+{
+ "connected": false,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "unknown"
+}
+```
+
+Restore the WSL network by restarting the distribution from PowerShell:
+
+```powershell
+wsl --shutdown
+wsl -d Ubuntu
+```
+
+## VirtualBox VM Setups
+
+Use snapshots before changing network state.
+
+Create at least two VMs:
+
+| VM | Suggested distro | Purpose |
+| -- | ---------------- | ------- |
+| `nm-vm` | Ubuntu Desktop or Fedora Workstation | NetworkManager D-Bus branch |
+| `fallback-vm` | Ubuntu Server, Debian, or Ubuntu Desktop | Passive fallback branch |
+| `hardware-vm` | Ubuntu Desktop or Fedora Workstation | USB Wi-Fi or USB modem tests |
+
+Start `nm-vm` with a single VirtualBox network adapter:
+
+| VirtualBox adapter mode | Useful coverage |
+| ----------------------- | --------------- |
+| NAT | Connected Ethernet with internet |
+| Bridged Adapter | Connected Ethernet with LAN behavior closer to hardware |
+| Host-only Adapter | Local network without internet |
+| Internal Network | Isolated guest network |
+| Cable connected unchecked | Disconnected |
+
+The plugin sees the virtual NIC as Ethernet for all of these modes.
+
+## NetworkManager Ethernet Scenarios
+
+Use `nm-vm` with a NAT or bridged adapter. Confirm NetworkManager owns the bus:
+
+```sh
+busctl --system list | grep org.freedesktop.NetworkManager
+nmcli networking on
+nmcli networking connectivity check
+```
+
+### Ethernet Unmetered
+
+```sh
+ACTIVE="$(nmcli -t -f NAME,DEVICE connection show --active | \
+ awk -F: '$2 != "lo" { print; exit }')"
+PROFILE="${ACTIVE%%:*}"
+IFACE="${ACTIVE##*:}"
+
+sudo nmcli connection modify "$PROFILE" connection.metered no
+sudo nmcli connection down "$PROFILE"
+sudo nmcli connection up "$PROFILE"
+nmcli networking connectivity check
+```
+
+Expected response:
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "ethernet"
+}
+```
+
+### Ethernet Metered
+
+```sh
+sudo nmcli connection modify "$PROFILE" connection.metered yes
+sudo nmcli connection down "$PROFILE"
+sudo nmcli connection up "$PROFILE"
+nmcli networking connectivity check
+```
+
+Expected response:
+
+```json
+{
+ "connected": true,
+ "metered": true,
+ "constrained": true,
+ "connectionType": "ethernet"
+}
+```
+
+Reset after the test:
+
+```sh
+sudo nmcli connection modify "$PROFILE" connection.metered no
+sudo nmcli connection down "$PROFILE"
+sudo nmcli connection up "$PROFILE"
+```
+
+### Ethernet Unknown Or Guessed Metered
+
+NetworkManager can guess a device's runtime metered state when the profile is
+`unknown`. The plugin treats NetworkManager `Metered` value `3` as metered.
+
+```sh
+sudo nmcli connection modify "$PROFILE" connection.metered unknown
+sudo nmcli connection down "$PROFILE"
+sudo nmcli connection up "$PROFILE"
+```
+
+Verify the runtime property:
+
+```sh
+DEVICE_PATH="$(nmcli -g GENERAL.DBUS-PATH device show "$IFACE")"
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ "$DEVICE_PATH" \
+ org.freedesktop.NetworkManager.Device \
+ Metered
+```
+
+If the value is `u 3`, expect the metered response. If the value is `u 4` or
+`u 2`, expect the unmetered response.
+
+## NetworkManager Disconnected Scenarios
+
+### Networking Off
+
+```sh
+sudo nmcli networking off
+nmcli networking connectivity
+```
+
+Expected response:
+
+```json
+{
+ "connected": false,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "unknown"
+}
+```
+
+Restore:
+
+```sh
+sudo nmcli networking on
+sudo nmcli connection up "$PROFILE"
+nmcli networking connectivity check
+```
+
+### Virtual Cable Disconnected
+
+In VirtualBox, open the VM network settings and uncheck `Cable connected`.
+Refresh the app.
+
+Expected response:
+
+```json
+{
+ "connected": false,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "unknown"
+}
+```
+
+Reconnect the cable and run:
+
+```sh
+sudo nmcli connection up "$PROFILE"
+nmcli networking connectivity check
+```
+
+### Limited, Local-Only, Or Captive Connectivity
+
+Use a Host-only Adapter for local-only VirtualBox networking.
+
+1. Shut down the VM.
+2. In VirtualBox, open `Tools` > `Network Manager` > `Host-only Networks`.
+3. Create a host-only network if one does not already exist. The default name is
+ usually `vboxnet0`.
+4. Keep DHCP enabled for the host-only network.
+5. Open the VM settings.
+6. Go to `Network` > `Adapter 1`.
+7. Set `Attached to` to `Host-only Adapter`.
+8. Set `Name` to the host-only network, for example `vboxnet0`.
+9. Keep `Cable connected` checked.
+10. Start the VM.
+
+After the VM boots, reconnect NetworkManager and capture the active profile:
+
+```sh
+sudo nmcli networking on
+
+ACTIVE="$(nmcli -t -f NAME,DEVICE connection show --active | \
+ awk -F: '$2 != "lo" { print; exit }')"
+PROFILE="${ACTIVE%%:*}"
+IFACE="${ACTIVE##*:}"
+
+sudo nmcli connection up "$PROFILE"
+```
+
+Verify that the guest has local network access but not internet access:
+
+```sh
+ip addr show "$IFACE"
+ip route
+ping -c 1 192.168.56.1 || true
+ping -c 1 1.1.1.1 || true
+```
+
+The host-only network commonly uses `192.168.56.1` for the host side. Use the
+address shown by VirtualBox if it differs.
+
+Then refresh NetworkManager's cached connectivity state:
+
+```sh
+nmcli networking connectivity check
+nmcli networking connectivity
+```
+
+If NetworkManager reports `none` or `limited`, the plugin should return
+disconnected:
+
+```json
+{
+ "connected": false,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "unknown"
+}
+```
+
+To cover captive portal behavior, use a real captive portal network or configure
+a temporary NetworkManager connectivity check in a test VM. NetworkManager
+reports `portal` when its check URI is reachable, but the response does not
+match the configured online response.
+
+Create a fake portal check:
+
+```sh
+sudo tee /etc/NetworkManager/conf.d/99-connectivity-portal-test.conf >/dev/null <<'EOF'
+[connectivity]
+enabled=true
+uri=http://example.com/
+response=NetworkManager is online
+interval=5
+EOF
+
+sudo systemctl restart NetworkManager
+```
+
+Force a connectivity check:
+
+```sh
+nmcli networking connectivity check
+nmcli networking connectivity
+```
+
+Expected `nmcli` output:
+
+```text
+portal
+```
+
+Confirm the D-Bus value read by the plugin:
+
+```sh
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ /org/freedesktop/NetworkManager \
+ org.freedesktop.NetworkManager \
+ Connectivity
+```
+
+Expected D-Bus output:
+
+```text
+u 2
+```
+
+Run the example app and refresh the status:
+
+```sh
+cd examples/tauri-app
+WEBKIT_DISABLE_COMPOSITING_MODE=1 npm run dev
+```
+
+If NetworkManager reports `portal`, the plugin should return connected and
+constrained:
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": true,
+ "connectionType": "ethernet"
+}
+```
+
+The `connectionType` may be `wifi` when testing on a real Wi-Fi captive portal.
+
+Remove the temporary portal check when the scenario is complete:
+
+```sh
+sudo rm /etc/NetworkManager/conf.d/99-connectivity-portal-test.conf
+sudo systemctl restart NetworkManager
+nmcli networking connectivity check
+```
+
+Restore the VM's normal internet path by shutting it down and changing
+`Adapter 1` back to `NAT` or `Bridged Adapter`.
+
+## NetworkManager Unknown Connectivity
+
+This scenario covers the branch where `Connectivity` is `unknown` and the plugin
+falls back to NetworkManager `State`.
+
+Create a temporary NetworkManager config:
+
+```sh
+sudo mkdir -p /etc/NetworkManager/conf.d
+printf '%s\n' \
+ '[connectivity]' \
+ 'enabled=false' \
+ | sudo tee /etc/NetworkManager/conf.d/99-connectivity-test.conf
+
+sudo systemctl restart NetworkManager
+```
+
+Check the D-Bus values:
+
+```sh
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ /org/freedesktop/NetworkManager \
+ org.freedesktop.NetworkManager \
+ Connectivity
+
+busctl get-property \
+ org.freedesktop.NetworkManager \
+ /org/freedesktop/NetworkManager \
+ org.freedesktop.NetworkManager \
+ State
+```
+
+If `Connectivity` is `u 0` and `State` is `u 70`, expected response is connected
+with the active device details. If `State` is not `u 70`, expected response is
+disconnected.
+
+Remove the temporary config after the scenario:
+
+```sh
+sudo rm /etc/NetworkManager/conf.d/99-connectivity-test.conf
+sudo systemctl restart NetworkManager
+nmcli networking connectivity check
+```
+
+## Passive Fallback In A VM
+
+Use `fallback-vm`. The simplest way to force fallback is to stop
+NetworkManager while keeping the current interface and route in place.
+
+```sh
+systemctl is-active NetworkManager || true
+ip route
+
+sudo systemctl stop NetworkManager
+busctl --system list | grep org.freedesktop.NetworkManager || true
+ip route
+```
+
+If the default route is still present, expected response is:
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "ethernet"
+}
+```
+
+If stopping NetworkManager removes the route, re-add a temporary route inside
+the VM. Replace the interface and gateway with values from `ip route` before the
+service was stopped.
+
+```sh
+sudo ip link set "$IFACE" up
+sudo ip route add default via "$GATEWAY" dev "$IFACE"
+```
+
+To cover fallback disconnected:
+
+```sh
+sudo ip route del default
+```
+
+Expected response:
+
+```json
+{
+ "connected": false,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "unknown"
+}
+```
+
+Restore:
+
+```sh
+sudo systemctl start NetworkManager
+sudo nmcli networking on
+sudo nmcli connection up "$PROFILE"
+```
+
+## Wi-Fi Scenarios
+
+Use a physical Linux machine with Wi-Fi or pass a USB Wi-Fi adapter through to a
+VirtualBox VM.
+
+Install Wi-Fi tooling if needed:
+
+```sh
+sudo apt install -y network-manager iw wireless-tools
+```
+
+Connect using NetworkManager:
+
+```sh
+nmcli device wifi list
+sudo nmcli device wifi connect "$SSID" password "$PASSWORD"
+nmcli connection show --active
+```
+
+Set the Wi-Fi profile to unmetered:
+
+```sh
+ACTIVE="$(nmcli -t -f NAME,DEVICE connection show --active | \
+ awk -F: '$2 != "lo" { print; exit }')"
+PROFILE="${ACTIVE%%:*}"
+
+sudo nmcli connection modify "$PROFILE" connection.metered no
+sudo nmcli connection down "$PROFILE"
+sudo nmcli connection up "$PROFILE"
+nmcli networking connectivity check
+```
+
+Expected unmetered response:
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "wifi"
+}
+```
+
+Set the Wi-Fi profile to metered:
+
+```sh
+sudo nmcli connection modify "$PROFILE" connection.metered yes
+sudo nmcli connection down "$PROFILE"
+sudo nmcli connection up "$PROFILE"
+nmcli networking connectivity check
+```
+
+Expected metered response:
+
+```json
+{
+ "connected": true,
+ "metered": true,
+ "constrained": true,
+ "connectionType": "wifi"
+}
+```
+
+To test Wi-Fi fallback classification, stop NetworkManager after the Wi-Fi
+connection is active and make sure the default route remains. The passive
+fallback checks `/sys/class/net/$IFACE/wireless` or an `80211` marker.
+
+## Cellular And ModemManager Scenarios
+
+These scenarios were not manually tested in this pass.
+
+Use a physical Linux machine with a WWAN modem or pass a USB modem through to a
+VirtualBox VM. Install ModemManager and enable WWAN:
+
+```sh
+sudo apt install -y modemmanager network-manager usbutils
+sudo systemctl enable --now ModemManager NetworkManager
+sudo nmcli radio wwan on
+mmcli -L
+```
+
+Create a GSM profile. Replace `$APN` and add username, password, or SIM PIN
+settings if the carrier requires them.
+
+```sh
+sudo nmcli connection add \
+ type gsm \
+ ifname "*" \
+ con-name test-cellular \
+ apn "$APN"
+
+sudo nmcli connection up test-cellular
+nmcli connection show --active
+```
+
+Expected unmetered, non-roaming response when the profile is not metered:
+
+```sh
+sudo nmcli connection modify test-cellular connection.metered no
+sudo nmcli connection down test-cellular
+sudo nmcli connection up test-cellular
+```
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "cellular"
+}
+```
+
+Expected metered response:
+
+```sh
+sudo nmcli connection modify test-cellular connection.metered yes
+sudo nmcli connection down test-cellular
+sudo nmcli connection up test-cellular
+```
+
+```json
+{
+ "connected": true,
+ "metered": true,
+ "constrained": true,
+ "connectionType": "cellular"
+}
+```
+
+To cover the ModemManager roaming branch, the modem must expose a roaming 3GPP
+registration state.
+
+Find the modem object path:
+
+```sh
+mmcli -L
+```
+
+Then verify the registration state:
+
+```sh
+busctl get-property \
+ org.freedesktop.ModemManager1 \
+ /org/freedesktop/ModemManager1/Modem/0 \
+ org.freedesktop.ModemManager1.Modem.Modem3gpp \
+ RegistrationState
+```
+
+When that value is `u 5`, expected response with `connection.metered no` is:
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": true,
+ "connectionType": "cellular"
+}
+```
+
+## Unknown Connection Type Scenarios
+
+Unknown type can happen when NetworkManager reports an unsupported device type,
+when no primary connection is available, or when the passive fallback finds a
+default route but no Wi-Fi, WWAN, or Ethernet marker in sysfs.
+
+Practical options:
+
+ * Connect through a VPN, tunnel, or uncommon virtual device and make it the
+ primary route.
+ * Use an isolated test VM with a custom interface managed outside
+ NetworkManager.
+ * Record any real environment where the observation commands show a default
+ route but the plugin returns `connectionType: "unknown"`.
+
+Expected connected unknown response:
+
+```json
+{
+ "connected": true,
+ "metered": false,
+ "constrained": false,
+ "connectionType": "unknown"
+}
+```
+
+If NetworkManager is active and the unknown device is metered, then constrained
+should also be true:
+
+```json
+{
+ "connected": true,
+ "metered": true,
+ "constrained": true,
+ "connectionType": "unknown"
+}
+```
diff --git a/examples/tauri-app/src-tauri/Cargo.lock b/examples/tauri-app/src-tauri/Cargo.lock
index 4d754db..1e4597a 100644
--- a/examples/tauri-app/src-tauri/Cargo.lock
+++ b/examples/tauri-app/src-tauri/Cargo.lock
@@ -47,6 +47,137 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "atk"
version = "0.18.2"
@@ -142,6 +273,19 @@ dependencies = [
"objc2",
]
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
[[package]]
name = "brotli"
version = "8.0.2"
@@ -322,6 +466,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -697,6 +850,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -714,6 +894,37 @@ dependencies = [
"typeid",
]
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -851,6 +1062,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1236,6 +1460,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
[[package]]
name = "hex"
version = "0.4.3"
@@ -1673,6 +1903,12 @@ dependencies = [
"selectors 0.24.0",
]
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -1728,6 +1964,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "litemap"
version = "0.8.2"
@@ -1791,6 +2033,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
[[package]]
name = "matches"
version = "0.1.10"
@@ -1902,6 +2153,15 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -2074,6 +2334,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "pango"
version = "0.18.3"
@@ -2099,6 +2369,12 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2321,6 +2597,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -2353,6 +2640,20 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2696,6 +2997,19 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2993,12 +3307,31 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -3327,6 +3660,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-connectivity",
+ "tracing-subscriber",
]
[[package]]
@@ -3420,6 +3754,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"windows 0.62.2",
+ "zbus",
]
[[package]]
@@ -3522,6 +3857,19 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "tendril"
version = "0.4.3"
@@ -3583,6 +3931,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "time"
version = "0.3.47"
@@ -3809,9 +4166,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
+ "tracing-attributes",
"tracing-core",
]
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -3819,6 +4188,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
]
[[package]]
@@ -3861,6 +4260,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -3969,6 +4379,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4744,6 +5160,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
[[package]]
name = "winnow"
@@ -4946,6 +5365,67 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 0.7.15",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
+dependencies = [
+ "serde",
+ "winnow 0.7.15",
+ "zvariant",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.48"
@@ -5025,3 +5505,43 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 0.7.15",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "winnow 0.7.15",
+]
diff --git a/examples/tauri-app/src-tauri/Cargo.toml b/examples/tauri-app/src-tauri/Cargo.toml
index c249048..7c73228 100644
--- a/examples/tauri-app/src-tauri/Cargo.toml
+++ b/examples/tauri-app/src-tauri/Cargo.toml
@@ -15,3 +15,4 @@ tauri-build = { version = "2.5.6", features = [] }
[dependencies]
tauri = { version = "2.9.3", features = [] }
tauri-plugin-connectivity = { path = "../../../" }
+tracing-subscriber = { version = "=0.3.23", features = ["env-filter", "fmt"] }
diff --git a/examples/tauri-app/src-tauri/src/lib.rs b/examples/tauri-app/src-tauri/src/lib.rs
index 2faa506..671352c 100644
--- a/examples/tauri-app/src-tauri/src/lib.rs
+++ b/examples/tauri-app/src-tauri/src/lib.rs
@@ -1,7 +1,21 @@
+#[cfg(debug_assertions)]
+const DEFAULT_LOG_FILTER: &str = "warn,tauri_plugin_connectivity=debug";
+#[cfg(not(debug_assertions))]
+const DEFAULT_LOG_FILTER: &str = "warn";
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
+ init_logging();
+
tauri::Builder::default()
.plugin(tauri_plugin_connectivity::init())
.run(tauri::generate_context!())
.expect("error while running connectivity example");
}
+
+fn init_logging() {
+ let filter = tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER));
+
+ let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
+}
diff --git a/guest-js/index.ts b/guest-js/index.ts
index ecffdb3..dad8226 100644
--- a/guest-js/index.ts
+++ b/guest-js/index.ts
@@ -25,6 +25,8 @@ export interface ConnectionStatus {
*
* Platform mapping:
* - **Windows:** `NetworkCostType` is `Unknown`, `Fixed`, or `Variable`
+ * - **Linux:** NetworkManager primary device `Metered` is `YES` or
+ * `GUESS_YES`; passive fallback defaults to `false`
* - **iOS:** `NWPath.isExpensive`
* - **Android:** absence of `NET_CAPABILITY_NOT_METERED`
*/
@@ -37,6 +39,9 @@ export interface ConnectionStatus {
* Platform mapping:
* - **Windows:** `ConstrainedInternetAccess`, `ApproachingDataLimit`,
* `OverDataLimit`, `Roaming`, or `BackgroundDataUsageRestricted`
+ * - **Linux:** NetworkManager `Connectivity` is `PORTAL`, primary device is
+ * metered, or ModemManager reports cellular roaming; passive fallback
+ * defaults to `false`
* - **iOS:** `NWPath.isConstrained` (Low Data Mode)
* - **Android:** Data Saver / `RESTRICT_BACKGROUND_STATUS`
*/
diff --git a/src/platform.rs b/src/platform.rs
index 91100f7..3107a2f 100644
--- a/src/platform.rs
+++ b/src/platform.rs
@@ -1,9 +1,13 @@
-#[cfg(not(target_os = "windows"))]
+#[cfg(target_os = "linux")]
+mod linux;
+#[cfg(not(any(target_os = "linux", target_os = "windows")))]
mod unsupported;
#[cfg(target_os = "windows")]
mod windows;
-#[cfg(not(target_os = "windows"))]
+#[cfg(target_os = "linux")]
+pub use linux::connection_status;
+#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub use unsupported::connection_status;
#[cfg(target_os = "windows")]
pub use windows::connection_status;
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
new file mode 100644
index 0000000..a9e00ff
--- /dev/null
+++ b/src/platform/linux.rs
@@ -0,0 +1,813 @@
+use std::fs;
+use std::path::Path;
+use std::time::Duration;
+
+use tracing::{debug, warn};
+use zbus::blocking::connection::Builder as ConnectionBuilder;
+use zbus::blocking::fdo::DBusProxy;
+use zbus::blocking::proxy::Builder as ProxyBuilder;
+use zbus::blocking::{Connection, Proxy};
+use zbus::names::BusName;
+use zbus::proxy::CacheProperties;
+use zbus::zvariant::{ObjectPath, OwnedObjectPath};
+
+use crate::error::Result;
+use crate::types::{ConnectionStatus, ConnectionType};
+
+const DBUS_TIMEOUT: Duration = Duration::from_secs(2);
+
+const DBUS_SERVICE: &str = "org.freedesktop.DBus";
+
+// NetworkManager exposes cached root properties for connection state. We read
+// `Connectivity` instead of calling `CheckConnectivity()` because that method
+// can issue a connectivity probe.
+// https://networkmanager.pages.freedesktop.org/NetworkManager/NetworkManager/gdbus-org.freedesktop.NetworkManager.html
+const NETWORK_MANAGER_SERVICE: &str = "org.freedesktop.NetworkManager";
+const NETWORK_MANAGER_PATH: &str = "/org/freedesktop/NetworkManager";
+const NETWORK_MANAGER_INTERFACE: &str = "org.freedesktop.NetworkManager";
+
+// The primary active connection points at the NetworkManager devices that carry
+// it; device properties provide transport, metered state, and the ModemManager
+// object path for modem devices.
+// https://networkmanager.pages.freedesktop.org/NetworkManager/NetworkManager/gdbus-org.freedesktop.NetworkManager.Connection.Active.html
+// https://www.networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.Device.html
+const NETWORK_MANAGER_ACTIVE_CONNECTION_INTERFACE: &str =
+ "org.freedesktop.NetworkManager.Connection.Active";
+const NETWORK_MANAGER_DEVICE_INTERFACE: &str = "org.freedesktop.NetworkManager.Device";
+
+// ModemManager is only used for cellular roaming. Missing service, missing 3GPP
+// interface, and read errors are treated as no roaming signal.
+// https://www.freedesktop.org/software/ModemManager/api/latest/gdbus-org.freedesktop.ModemManager1.Modem.Modem3gpp.html
+const MODEM_MANAGER_SERVICE: &str = "org.freedesktop.ModemManager1";
+const MODEM_MANAGER_MODEM_PREFIX: &str = "/org/freedesktop/ModemManager1/Modem/";
+const MODEM_MANAGER_3GPP_INTERFACE: &str = "org.freedesktop.ModemManager1.Modem.Modem3gpp";
+
+// NetworkManager D-Bus enum values
+// https://networkmanager.pages.freedesktop.org/NetworkManager/NetworkManager/nm-dbus-types.html
+const NM_CONNECTIVITY_NONE: u32 = 1;
+const NM_CONNECTIVITY_PORTAL: u32 = 2;
+const NM_CONNECTIVITY_LIMITED: u32 = 3;
+const NM_CONNECTIVITY_FULL: u32 = 4;
+
+const NM_STATE_CONNECTED_GLOBAL: u32 = 70;
+
+const NM_DEVICE_TYPE_ETHERNET: u32 = 1;
+const NM_DEVICE_TYPE_WIFI: u32 = 2;
+const NM_DEVICE_TYPE_MODEM: u32 = 8;
+
+const NM_METERED_YES: u32 = 1;
+const NM_METERED_GUESS_YES: u32 = 3;
+
+const MM_MODEM_3GPP_REGISTRATION_STATE_ROAMING: u32 = 5;
+
+// Passive fallback inputs. This path intentionally avoids DNS, ping, HTTP, or
+// any other active reachability probe.
+const PROC_NET_ROUTE: &str = "/proc/net/route";
+const SYS_CLASS_NET: &str = "/sys/class/net";
+const LINUX_ARPHRD_ETHER: u32 = 1;
+const LINUX_ROUTE_FLAG_UP: u32 = 0x1;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ConnectedState {
+ Connected,
+ Constrained,
+ Disconnected,
+ Unknown,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct ConnectionDetails {
+ metered: bool,
+ roaming: bool,
+ connection_type: ConnectionType,
+}
+
+impl Default for ConnectionDetails {
+ fn default() -> Self {
+ Self {
+ metered: false,
+ roaming: false,
+ connection_type: ConnectionType::Unknown,
+ }
+ }
+}
+
+/// Returns the current Linux network connection status.
+///
+/// NetworkManager is preferred when available because it exposes cached
+/// connectivity, primary-route, transport, and metered state over D-Bus. Systems
+/// without NetworkManager fall back to passive kernel state only.
+pub fn connection_status() -> Result {
+ debug!("querying Linux connection status");
+
+ let connection = match system_bus_connection() {
+ Ok(connection) => {
+ debug!("connected to Linux system D-Bus");
+ connection
+ }
+ Err(error) => {
+ warn!(%error, "failed to connect to Linux system bus; using passive fallback");
+ return Ok(fallback_connection_status());
+ }
+ };
+
+ match service_has_owner(&connection, NETWORK_MANAGER_SERVICE) {
+ Ok(true) => {
+ debug!("NetworkManager service is present");
+
+ match network_manager_connection_status(&connection) {
+ Ok(status) => {
+ debug!(
+ ?status,
+ "resolved Linux connection status via NetworkManager"
+ );
+ Ok(status)
+ }
+ Err(error) => {
+ warn!(%error, "failed to query NetworkManager; using passive fallback");
+ Ok(fallback_connection_status())
+ }
+ }
+ }
+ Ok(false) => {
+ debug!("NetworkManager service is not present; using passive fallback");
+ Ok(fallback_connection_status())
+ }
+ Err(error) => {
+ warn!(%error, "failed to probe NetworkManager service; using passive fallback");
+ Ok(fallback_connection_status())
+ }
+ }
+}
+
+fn system_bus_connection() -> zbus::Result {
+ ConnectionBuilder::system()?
+ .method_timeout(DBUS_TIMEOUT)
+ .build()
+}
+
+fn network_manager_connection_status(connection: &Connection) -> zbus::Result {
+ let manager = dbus_proxy(
+ connection,
+ NETWORK_MANAGER_SERVICE,
+ NETWORK_MANAGER_PATH,
+ NETWORK_MANAGER_INTERFACE,
+ )?;
+
+ // `Connectivity` is a cached property. `FULL` maps to full connectivity,
+ // `PORTAL` maps to connected but constrained, and `UNKNOWN` falls back to
+ // NM's broader networking state.
+ let connectivity = manager.get_property::("Connectivity")?;
+ debug!(connectivity, "queried NetworkManager connectivity state");
+
+ let connectivity_state = map_connectivity(connectivity);
+ let connected = match connectivity_state {
+ ConnectedState::Connected => true,
+ ConnectedState::Constrained => true,
+ ConnectedState::Disconnected => false,
+ ConnectedState::Unknown => {
+ let state = manager.get_property::("State")?;
+ debug!(
+ connectivity,
+ state, "NetworkManager connectivity is unknown; falling back to state"
+ );
+ has_global_connectivity(state)
+ }
+ };
+
+ if !connected {
+ debug!(
+ connectivity,
+ "NetworkManager connectivity does not indicate active internet access"
+ );
+ return Ok(ConnectionStatus::disconnected());
+ }
+
+ let details = match primary_connection_details(connection, &manager) {
+ Ok(details) => details,
+ Err(error) => {
+ warn!(%error, "failed to resolve Linux primary connection details");
+ ConnectionDetails::default()
+ }
+ };
+
+ Ok(ConnectionStatus {
+ connected: true,
+ metered: details.metered,
+ constrained: is_constrained(connectivity_state, details.metered, details.roaming),
+ connection_type: details.connection_type,
+ })
+}
+
+fn primary_connection_details(
+ connection: &Connection,
+ manager: &Proxy<'_>,
+) -> zbus::Result {
+ // NetworkManager chooses the primary connection for the default route. Its
+ // active connection object is the stable way to find the devices that should
+ // drive transport and metered decisions.
+ let primary_connection = manager.get_property::("PrimaryConnection")?;
+ debug!(
+ primary_connection = %primary_connection.as_str(),
+ "queried NetworkManager primary connection"
+ );
+
+ if is_root_path(&primary_connection) {
+ debug!("NetworkManager returned no primary connection");
+ return Ok(ConnectionDetails::default());
+ }
+
+ let active_connection = dbus_proxy(
+ connection,
+ NETWORK_MANAGER_SERVICE,
+ primary_connection.as_str(),
+ NETWORK_MANAGER_ACTIVE_CONNECTION_INTERFACE,
+ )?;
+ let devices = active_connection.get_property::>("Devices")?;
+ debug!(
+ device_count = devices.len(),
+ primary_connection = %primary_connection.as_str(),
+ "queried NetworkManager primary connection devices"
+ );
+
+ if devices.is_empty() {
+ debug!("NetworkManager primary connection has no devices");
+ return Ok(ConnectionDetails::default());
+ }
+
+ let mut details = ConnectionDetails::default();
+ let mut read_any_device = false;
+
+ for device in devices {
+ match device_details(connection, &device) {
+ Ok(device_details) => {
+ read_any_device = true;
+ details.metered |= device_details.metered;
+ details.roaming |= device_details.roaming;
+
+ if details.connection_type == ConnectionType::Unknown {
+ details.connection_type = device_details.connection_type.clone();
+ }
+
+ debug!(
+ device = %device.as_str(),
+ metered = device_details.metered,
+ roaming = device_details.roaming,
+ connection_type = ?device_details.connection_type,
+ "resolved NetworkManager device details"
+ );
+ }
+ Err(error) => {
+ warn!(%error, device = %device.as_str(), "failed to read NetworkManager device");
+ }
+ }
+ }
+
+ if !read_any_device {
+ debug!("failed to read any NetworkManager primary connection devices");
+ }
+
+ Ok(details)
+}
+
+fn device_details(
+ connection: &Connection,
+ device: &OwnedObjectPath,
+) -> zbus::Result {
+ let device_proxy = dbus_proxy(
+ connection,
+ NETWORK_MANAGER_SERVICE,
+ device.as_str(),
+ NETWORK_MANAGER_DEVICE_INTERFACE,
+ )?;
+
+ // DeviceType gives the transport class; Metered lives on the device, not on
+ // the active connection.
+ let device_type = device_proxy.get_property::("DeviceType")?;
+ let connection_type = map_device_type(device_type);
+ debug!(
+ device = %device.as_str(),
+ device_type,
+ connection_type = ?connection_type,
+ "queried NetworkManager device type"
+ );
+
+ let metered = match device_proxy.get_property::("Metered") {
+ Ok(metered) => {
+ let is_metered = is_metered(metered);
+ debug!(
+ device = %device.as_str(),
+ metered,
+ is_metered,
+ "queried NetworkManager device metered state"
+ );
+ is_metered
+ }
+ Err(error) => {
+ warn!(%error, device = %device.as_str(), "failed to read NetworkManager device metered state");
+ false
+ }
+ };
+ let roaming = if device_type == NM_DEVICE_TYPE_MODEM {
+ modem_is_roaming(connection, &device_proxy)
+ } else {
+ false
+ };
+
+ Ok(ConnectionDetails {
+ metered,
+ roaming,
+ connection_type,
+ })
+}
+
+fn modem_is_roaming(connection: &Connection, device_proxy: &Proxy<'_>) -> bool {
+ // NM modem devices expose a `Udi` that usually points at the corresponding
+ // ModemManager object. Only that object can tell us whether the cellular
+ // registration state is roaming.
+ match service_has_owner(connection, MODEM_MANAGER_SERVICE) {
+ Ok(true) => {}
+ Ok(false) => {
+ debug!("ModemManager service is not present; skipping roaming check");
+ return false;
+ }
+ Err(error) => {
+ warn!(%error, "failed to probe ModemManager service; skipping roaming check");
+ return false;
+ }
+ }
+
+ let udi = match device_proxy.get_property::("Udi") {
+ Ok(udi) => {
+ debug!(udi, "queried NetworkManager modem Udi");
+ udi
+ }
+ Err(error) => {
+ warn!(%error, "failed to read NetworkManager modem Udi; skipping roaming check");
+ return false;
+ }
+ };
+
+ if !is_modem_manager_modem_path(&udi) {
+ debug!(
+ udi,
+ "NetworkManager modem Udi is not a ModemManager modem path"
+ );
+ return false;
+ }
+
+ let modem_path = match ObjectPath::try_from(udi.as_str()) {
+ Ok(path) => path,
+ Err(error) => {
+ warn!(%error, udi, "NetworkManager modem Udi is not a valid D-Bus object path");
+ return false;
+ }
+ };
+
+ let modem = match dbus_proxy(
+ connection,
+ MODEM_MANAGER_SERVICE,
+ modem_path.as_str(),
+ MODEM_MANAGER_3GPP_INTERFACE,
+ ) {
+ Ok(modem) => modem,
+ Err(error) => {
+ warn!(%error, "failed to create ModemManager proxy; skipping roaming check");
+ return false;
+ }
+ };
+
+ match modem.get_property::("RegistrationState") {
+ Ok(registration_state) => {
+ let roaming = is_roaming(registration_state);
+ debug!(
+ registration_state,
+ roaming, "queried ModemManager 3GPP registration state"
+ );
+ roaming
+ }
+ Err(error) => {
+ warn!(%error, "failed to read ModemManager 3GPP registration state");
+ false
+ }
+ }
+}
+
+fn dbus_proxy<'a>(
+ connection: &'a Connection,
+ destination: &'a str,
+ path: &'a str,
+ interface: &'a str,
+) -> zbus::Result> {
+ ProxyBuilder::new(connection)
+ .destination(destination)?
+ .path(path)?
+ .interface(interface)?
+ .cache_properties(CacheProperties::No)
+ .build()
+}
+
+fn service_has_owner(connection: &Connection, service: &str) -> zbus::Result {
+ let proxy = DBusProxy::builder(connection)
+ .destination(DBUS_SERVICE)?
+ .cache_properties(CacheProperties::No)
+ .build()?;
+ let service_name = BusName::try_from(service)?;
+
+ Ok(proxy.name_has_owner(service_name)?)
+}
+
+fn fallback_connection_status() -> ConnectionStatus {
+ // Systems that do not run NetworkManager still commonly expose their IPv4
+ // routing table through /proc. An up, non-loopback default route is the
+ // strongest passive signal available without probing the network.
+ let route_table = match fs::read_to_string(PROC_NET_ROUTE) {
+ Ok(route_table) => route_table,
+ Err(error) => {
+ warn!(%error, "failed to read Linux route table");
+ return ConnectionStatus::disconnected();
+ }
+ };
+
+ let Some(iface) = default_route_interface(&route_table) else {
+ debug!("Linux route table does not contain an up, non-loopback default route");
+ return ConnectionStatus::disconnected();
+ };
+
+ let connection_type = infer_transport_from_sysfs(Path::new(SYS_CLASS_NET), &iface);
+ let status = ConnectionStatus {
+ connected: true,
+ metered: false,
+ constrained: false,
+ connection_type,
+ };
+
+ debug!(
+ iface,
+ connection_type = ?status.connection_type,
+ "resolved Linux connection status via passive fallback"
+ );
+
+ status
+}
+
+fn map_connectivity(connectivity: u32) -> ConnectedState {
+ match connectivity {
+ NM_CONNECTIVITY_FULL => ConnectedState::Connected,
+ NM_CONNECTIVITY_PORTAL => ConnectedState::Constrained,
+ NM_CONNECTIVITY_NONE | NM_CONNECTIVITY_LIMITED => ConnectedState::Disconnected,
+ _ => ConnectedState::Unknown,
+ }
+}
+
+fn has_global_connectivity(state: u32) -> bool {
+ state == NM_STATE_CONNECTED_GLOBAL
+}
+
+fn map_device_type(device_type: u32) -> ConnectionType {
+ match device_type {
+ NM_DEVICE_TYPE_ETHERNET => ConnectionType::Ethernet,
+ NM_DEVICE_TYPE_WIFI => ConnectionType::Wifi,
+ NM_DEVICE_TYPE_MODEM => ConnectionType::Cellular,
+ _ => ConnectionType::Unknown,
+ }
+}
+
+fn is_metered(metered: u32) -> bool {
+ matches!(metered, NM_METERED_YES | NM_METERED_GUESS_YES)
+}
+
+fn is_constrained(connectivity_state: ConnectedState, metered: bool, roaming: bool) -> bool {
+ // NetworkManager does not expose a separate background-data policy signal.
+ // Treat an explicitly or guessed metered primary device as constrained so
+ // callers can avoid discretionary data use on Linux.
+ connectivity_state == ConnectedState::Constrained || metered || roaming
+}
+
+fn is_roaming(registration_state: u32) -> bool {
+ registration_state == MM_MODEM_3GPP_REGISTRATION_STATE_ROAMING
+}
+
+fn is_modem_manager_modem_path(path: &str) -> bool {
+ path.starts_with(MODEM_MANAGER_MODEM_PREFIX) && ObjectPath::try_from(path).is_ok()
+}
+
+fn is_root_path(path: &OwnedObjectPath) -> bool {
+ path.as_str() == "/"
+}
+
+fn default_route_interface(route_table: &str) -> Option {
+ route_table.lines().skip(1).find_map(|line| {
+ let fields: Vec<_> = line.split_whitespace().collect();
+
+ if fields.len() < 4 {
+ return None;
+ }
+
+ let iface = fields[0];
+ let destination = fields[1];
+ let flags = fields[3];
+
+ if destination == "00000000" && iface != "lo" && route_is_up(flags) {
+ Some(iface.to_owned())
+ } else {
+ None
+ }
+ })
+}
+
+fn route_is_up(flags: &str) -> bool {
+ u32::from_str_radix(flags, 16).is_ok_and(|flags| flags & LINUX_ROUTE_FLAG_UP != 0)
+}
+
+fn infer_transport_from_sysfs(sys_class_net: &Path, iface: &str) -> ConnectionType {
+ let interface_path = sys_class_net.join(iface);
+
+ if interface_path.join("wireless").exists() || has_child_path_marker(&interface_path, "80211") {
+ debug!(iface, "sysfs classified fallback interface as Wi-Fi");
+ return ConnectionType::Wifi;
+ }
+
+ if has_wwan_marker(&interface_path) {
+ debug!(iface, "sysfs classified fallback interface as cellular");
+ return ConnectionType::Cellular;
+ }
+
+ if read_u32(interface_path.join("type")).is_some_and(|value| value == LINUX_ARPHRD_ETHER) {
+ debug!(iface, "sysfs classified fallback interface as Ethernet");
+ return ConnectionType::Ethernet;
+ }
+
+ debug!(iface, "sysfs could not classify fallback interface");
+ ConnectionType::Unknown
+}
+
+fn has_wwan_marker(interface_path: &Path) -> bool {
+ interface_path.join("wwan").exists()
+ || interface_path.join("device").join("wwan").exists()
+ || path_has_component(interface_path.join("device").join("subsystem"), "wwan")
+}
+
+fn has_child_path_marker(interface_path: &Path, marker: &str) -> bool {
+ path_has_component(interface_path, marker)
+ || fs::read_dir(interface_path)
+ .ok()
+ .into_iter()
+ .flatten()
+ .filter_map(|entry| entry.ok())
+ .any(|entry| path_has_component(entry.path(), marker))
+}
+
+fn path_has_component(path: impl AsRef, marker: &str) -> bool {
+ let path = fs::canonicalize(path).unwrap_or_default();
+
+ path.components().any(|component| {
+ component
+ .as_os_str()
+ .to_string_lossy()
+ .to_ascii_lowercase()
+ .contains(marker)
+ })
+}
+
+fn read_u32(path: impl AsRef) -> Option {
+ fs::read_to_string(path).ok()?.trim().parse().ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs::{self, File};
+ use std::io::Write;
+ use std::path::PathBuf;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
+
+ #[test]
+ fn maps_connectivity_states() {
+ assert_eq!(
+ map_connectivity(NM_CONNECTIVITY_FULL),
+ ConnectedState::Connected
+ );
+ assert_eq!(
+ map_connectivity(NM_CONNECTIVITY_NONE),
+ ConnectedState::Disconnected
+ );
+ assert_eq!(
+ map_connectivity(NM_CONNECTIVITY_PORTAL),
+ ConnectedState::Constrained
+ );
+ assert_eq!(
+ map_connectivity(NM_CONNECTIVITY_LIMITED),
+ ConnectedState::Disconnected
+ );
+ assert_eq!(map_connectivity(0), ConnectedState::Unknown);
+ assert_eq!(map_connectivity(99), ConnectedState::Unknown);
+ }
+
+ #[test]
+ fn falls_back_to_global_state_only_for_unknown_connectivity() {
+ assert!(has_global_connectivity(NM_STATE_CONNECTED_GLOBAL));
+ assert!(!has_global_connectivity(60));
+ assert!(!has_global_connectivity(20));
+ }
+
+ #[test]
+ fn identifies_metered_states() {
+ assert!(!is_metered(0));
+ assert!(is_metered(NM_METERED_YES));
+ assert!(!is_metered(2));
+ assert!(is_metered(NM_METERED_GUESS_YES));
+ assert!(!is_metered(4));
+ }
+
+ #[test]
+ fn maps_network_manager_device_types() {
+ assert_eq!(
+ map_device_type(NM_DEVICE_TYPE_ETHERNET),
+ ConnectionType::Ethernet
+ );
+ assert_eq!(map_device_type(NM_DEVICE_TYPE_WIFI), ConnectionType::Wifi);
+ assert_eq!(
+ map_device_type(NM_DEVICE_TYPE_MODEM),
+ ConnectionType::Cellular
+ );
+ assert_eq!(map_device_type(999), ConnectionType::Unknown);
+ }
+
+ #[test]
+ fn treats_metering_or_roaming_as_constrained() {
+ assert!(!is_constrained(ConnectedState::Connected, false, false));
+ assert!(is_constrained(ConnectedState::Constrained, false, false));
+ assert!(is_constrained(ConnectedState::Connected, true, false));
+ assert!(is_constrained(ConnectedState::Connected, false, true));
+ assert!(is_constrained(ConnectedState::Connected, true, true));
+ }
+
+ #[test]
+ fn detects_roaming_registration_state() {
+ assert!(is_roaming(MM_MODEM_3GPP_REGISTRATION_STATE_ROAMING));
+ assert!(!is_roaming(1));
+ }
+
+ #[test]
+ fn validates_modem_manager_modem_paths() {
+ assert!(is_modem_manager_modem_path(
+ "/org/freedesktop/ModemManager1/Modem/0"
+ ));
+ assert!(!is_modem_manager_modem_path("/"));
+ assert!(!is_modem_manager_modem_path(
+ "/org/freedesktop/NetworkManager/Devices/0"
+ ));
+ }
+
+ #[test]
+ fn parses_default_route_interface() {
+ let route_table = "\
+Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
+eth0\t00000000\t015018AC\t0003\t0\t0\t0\t00000000\t0\t0\t0
+";
+
+ assert_eq!(default_route_interface(route_table), Some("eth0".into()));
+ }
+
+ #[test]
+ fn ignores_loopback_default_route() {
+ let route_table = "\
+Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
+lo\t00000000\t00000000\t0003\t0\t0\t0\t00000000\t0\t0\t0
+";
+
+ assert_eq!(default_route_interface(route_table), None);
+ }
+
+ #[test]
+ fn ignores_down_default_route() {
+ let route_table = "\
+Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
+eth0\t00000000\t015018AC\t0002\t0\t0\t0\t00000000\t0\t0\t0
+";
+
+ assert_eq!(default_route_interface(route_table), None);
+ }
+
+ #[test]
+ fn returns_none_without_default_route() {
+ let route_table = "\
+Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
+eth0\t005018AC\t00000000\t0001\t0\t0\t0\t00F0FFFF\t0\t0\t0
+";
+
+ assert_eq!(default_route_interface(route_table), None);
+ }
+
+ #[test]
+ fn ignores_malformed_route_rows() {
+ let route_table = "\
+Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
+malformed
+eth0\t00000000\t015018AC\t0003\t0\t0\t0\t00000000\t0\t0\t0
+";
+
+ assert_eq!(default_route_interface(route_table), Some("eth0".into()));
+ }
+
+ #[test]
+ fn infers_wifi_from_wireless_directory() {
+ let temp = TempDir::new();
+ let iface = temp.path().join("wlp0s20f3");
+ fs::create_dir_all(iface.join("wireless")).unwrap();
+
+ assert_eq!(
+ infer_transport_from_sysfs(temp.path(), "wlp0s20f3"),
+ ConnectionType::Wifi
+ );
+ }
+
+ #[test]
+ fn infers_wifi_from_80211_child_marker() {
+ let temp = TempDir::new();
+ let iface = temp.path().join("net0");
+ fs::create_dir_all(iface.join("ieee80211")).unwrap();
+
+ assert_eq!(
+ infer_transport_from_sysfs(temp.path(), "net0"),
+ ConnectionType::Wifi
+ );
+ }
+
+ #[test]
+ fn infers_cellular_from_wwan_marker() {
+ let temp = TempDir::new();
+ let iface = temp.path().join("net0");
+ fs::create_dir_all(iface.join("device").join("wwan")).unwrap();
+
+ assert_eq!(
+ infer_transport_from_sysfs(temp.path(), "net0"),
+ ConnectionType::Cellular
+ );
+ }
+
+ #[test]
+ fn infers_ethernet_from_arphrd_ether_type() {
+ let temp = TempDir::new();
+ let iface = temp.path().join("enp0s1");
+ fs::create_dir_all(&iface).unwrap();
+ write_file(iface.join("type"), "1\n");
+
+ assert_eq!(
+ infer_transport_from_sysfs(temp.path(), "enp0s1"),
+ ConnectionType::Ethernet
+ );
+ }
+
+ #[test]
+ fn returns_unknown_when_sysfs_has_no_transport_signal() {
+ let temp = TempDir::new();
+ let iface = temp.path().join("net0");
+ fs::create_dir_all(&iface).unwrap();
+ write_file(iface.join("type"), "772\n");
+
+ assert_eq!(
+ infer_transport_from_sysfs(temp.path(), "net0"),
+ ConnectionType::Unknown
+ );
+ }
+
+ fn write_file(path: impl AsRef, contents: &str) {
+ let mut file = File::create(path).unwrap();
+ file.write_all(contents.as_bytes()).unwrap();
+ }
+
+ struct TempDir {
+ path: PathBuf,
+ }
+
+ impl TempDir {
+ fn new() -> Self {
+ let id = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
+ let path = std::env::temp_dir().join(format!(
+ "tauri-plugin-connectivity-linux-test-{}-{id}",
+ std::process::id()
+ ));
+
+ if path.exists() {
+ fs::remove_dir_all(&path).unwrap();
+ }
+ fs::create_dir_all(&path).unwrap();
+
+ Self { path }
+ }
+
+ fn path(&self) -> &Path {
+ &self.path
+ }
+ }
+
+ impl Drop for TempDir {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.path);
+ }
+ }
+}
diff --git a/src/types.rs b/src/types.rs
index 646d097..bd6ad18 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -35,6 +35,8 @@ pub struct ConnectionStatus {
///
/// Platform mapping:
/// - **Windows:** `NetworkCostType` is `Unknown`, `Fixed`, or `Variable`
+ /// - **Linux:** NetworkManager primary device `Metered` is `YES` or
+ /// `GUESS_YES`; passive fallback defaults to `false`
/// - **iOS:** `NWPath.isExpensive`
/// - **Android:** absence of `NET_CAPABILITY_NOT_METERED`
pub metered: bool,
@@ -45,6 +47,9 @@ pub struct ConnectionStatus {
/// Platform mapping:
/// - **Windows:** `ConstrainedInternetAccess`, `ApproachingDataLimit`,
/// `OverDataLimit`, `Roaming`, or `BackgroundDataUsageRestricted`
+ /// - **Linux:** NetworkManager `Connectivity` is `PORTAL`, primary device is
+ /// metered, or ModemManager reports cellular roaming; passive fallback
+ /// defaults to `false`
/// - **iOS:** `NWPath.isConstrained` (Low Data Mode)
/// - **Android:** Data Saver / `RESTRICT_BACKGROUND_STATUS`
pub constrained: bool,