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,