From fa22b556dd86186845eaf57be464eb0eac81d3f0 Mon Sep 17 00:00:00 2001 From: Michael Murphy Date: Fri, 13 Sep 2024 21:45:49 +0200 Subject: [PATCH] feat(networking): add VPN, WiFi, and Wired network pages --- Cargo.lock | 337 +++++-- Cargo.toml | 4 + cosmic-settings/Cargo.toml | 6 +- cosmic-settings/src/app.rs | 26 +- cosmic-settings/src/main.rs | 6 + .../src/pages/desktop/wallpaper/mod.rs | 13 +- cosmic-settings/src/pages/mod.rs | 4 + .../src/pages/networking/accounts.rs | 10 - cosmic-settings/src/pages/networking/mod.rs | 61 +- .../src/pages/networking/vpn/mod.rs | 877 ++++++++++++++++++ .../src/pages/networking/vpn/nmcli.rs | 49 + cosmic-settings/src/pages/networking/wifi.rs | 787 ++++++++++++++++ cosmic-settings/src/pages/networking/wired.rs | 692 +++++++++++++- cosmic-settings/src/utils.rs | 12 + debian/control | 3 + debian/install | 3 + i18n/en/cosmic_settings.ftl | 67 +- justfile | 9 + page/src/lib.rs | 5 + .../com.system76.CosmicSettings.Vpn.desktop | 12 + .../com.system76.CosmicSettings.Wired.desktop | 12 + ...m.system76.CosmicSettings.Wireless.desktop | 12 + 22 files changed, 2876 insertions(+), 131 deletions(-) delete mode 100644 cosmic-settings/src/pages/networking/accounts.rs create mode 100644 cosmic-settings/src/pages/networking/vpn/mod.rs create mode 100644 cosmic-settings/src/pages/networking/vpn/nmcli.rs create mode 100644 cosmic-settings/src/pages/networking/wifi.rs create mode 100644 resources/com.system76.CosmicSettings.Vpn.desktop create mode 100644 resources/com.system76.CosmicSettings.Wired.desktop create mode 100644 resources/com.system76.CosmicSettings.Wireless.desktop diff --git a/Cargo.lock b/Cargo.lock index 6d70fb6..95274d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,7 +267,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -288,6 +288,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as-result" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3702cac3c1601410cd655ae41650c4c87f7c3183dca6d1cd9acc4220ed56a8b7" + [[package]] name = "ash" version = "0.37.3+1.3.251" @@ -519,7 +525,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -548,13 +554,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.81" +version = "0.1.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -704,7 +710,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -828,7 +834,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "syn_derive", ] @@ -900,7 +906,7 @@ checksum = "0cc8b54b395f2fcfbb3d90c47b01c7f444d94d05bdeb775811dec868ac3bbc26" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1093,7 +1099,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -1105,7 +1111,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1406,7 +1412,7 @@ dependencies = [ [[package]] name = "cosmic-bg-config" version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-bg#e5e91d93fb7cd7e917922eceaddd4d412df46f93" +source = "git+https://github.com/pop-os/cosmic-bg#876987a5e7b76d8005dcaf969baf0dd419296dab" dependencies = [ "colorgrad", "cosmic-config", @@ -1431,7 +1437,7 @@ dependencies = [ [[package]] name = "cosmic-comp-config" version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-comp#ed64e26faf4b97221f0883bc1113f997acbadc50" +source = "git+https://github.com/pop-os/cosmic-comp#52280e9823506a480248e84caaf7545426a871c8" dependencies = [ "cosmic-config", "input", @@ -1441,7 +1447,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1463,12 +1469,26 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "quote", "syn 1.0.109", ] +[[package]] +name = "cosmic-dbus-networkmanager" +version = "0.1.0" +source = "git+https://github.com/pop-os/dbus-settings-bindings#e0d6a04d6ebf6bcede1580721c84a7f01e5ef8bb" +dependencies = [ + "bitflags 2.6.0", + "derive_builder", + "procfs", + "thiserror", + "time", + "zbus 4.4.0", + "zvariant 4.2.0", +] + [[package]] name = "cosmic-panel-config" version = "0.1.0" @@ -1505,7 +1525,7 @@ source = "git+https://github.com/pop-os/cosmic-randr#71fabbb382fa8cf750f50fb77c4 dependencies = [ "cosmic-protocols", "futures-lite 2.3.0", - "indexmap 2.4.0", + "indexmap 2.5.0", "tachyonix", "thiserror", "tokio", @@ -1530,6 +1550,7 @@ name = "cosmic-settings" version = "0.1.0" dependencies = [ "anyhow", + "as-result", "ashpd 0.9.1", "async-channel", "chrono", @@ -1538,6 +1559,7 @@ dependencies = [ "cosmic-bg-config", "cosmic-comp-config", "cosmic-config", + "cosmic-dbus-networkmanager", "cosmic-panel-config", "cosmic-randr", "cosmic-randr-shell", @@ -1550,6 +1572,7 @@ dependencies = [ "derive_setters", "dirs", "downcast-rs", + "eyre", "freedesktop-desktop-entry", "futures", "hostname-validator", @@ -1558,7 +1581,7 @@ dependencies = [ "i18n-embed-fl", "icu", "image 0.25.2", - "indexmap 2.4.0", + "indexmap 2.5.0", "itertools 0.13.0", "itoa", "libcosmic", @@ -1567,6 +1590,7 @@ dependencies = [ "regex", "ron", "rust-embed", + "secure-string", "serde", "slab", "slotmap", @@ -1600,7 +1624,7 @@ dependencies = [ [[package]] name = "cosmic-settings-daemon" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#7aedc25e3295b95a90eb710f443029d4ec920aa8" +source = "git+https://github.com/pop-os/dbus-settings-bindings#e0d6a04d6ebf6bcede1580721c84a7f01e5ef8bb" dependencies = [ "zbus 4.4.0", ] @@ -1623,16 +1647,21 @@ dependencies = [ [[package]] name = "cosmic-settings-subscriptions" version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-settings-subscriptions#f6fa655e4b74a5bd2dbfc2f6fdd94bc78f5e4fcc" +source = "git+https://github.com/pop-os/cosmic-settings-subscriptions#90df5c4b22c47f0e0213dcf2025519b0312437df" dependencies = [ + "cosmic-dbus-networkmanager", "futures", "iced_futures", + "itertools 0.13.0", "libpulse-binding", "log", "pipewire", "rustix 0.38.35", + "secure-string", + "thiserror", "tokio", "tokio-stream", + "tracing", "upower_dbus", "zbus 4.4.0", ] @@ -1669,7 +1698,7 @@ dependencies = [ [[package]] name = "cosmic-text" version = "0.12.1" -source = "git+https://github.com/pop-os/cosmic-text.git#e16b39f29c84773a05457fe39577a602de27855c" +source = "git+https://github.com/pop-os/cosmic-text.git#c7512170201910cfb8a021f8d749c4125dfed18d" dependencies = [ "bitflags 2.6.0", "fontdb", @@ -1679,6 +1708,7 @@ dependencies = [ "rustc-hash", "rustybuzz 0.14.1", "self_cell 1.0.4", + "smol_str", "swash", "sys-locale", "ttf-parser 0.21.1", @@ -1691,7 +1721,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "almost", "cosmic-config", @@ -1811,14 +1841,38 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -1831,8 +1885,19 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", - "syn 2.0.76", + "strsim 0.11.1", + "syn 2.0.77", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", ] [[package]] @@ -1841,9 +1906,9 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1887,16 +1952,47 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_setters" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e8ef033054e131169b8f0f9a7af8f5533a9436fadf3c500ed547f730f07090d" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -1954,7 +2050,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -2073,7 +2169,7 @@ checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -2377,7 +2473,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -2415,7 +2511,7 @@ dependencies = [ "gettext-rs", "log", "memchr", - "strsim", + "strsim 0.11.1", "textdistance", "thiserror", "xdg", @@ -2534,7 +2630,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -2613,9 +2709,9 @@ dependencies = [ [[package]] name = "gettext-rs" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49ea8a8fad198aaa1f9655a2524b64b70eb06b2f3ff37da407566c93054f364" +checksum = "4a6716b8a0db461a2720b850ba1623e5b69e4b1aa0224cf5e1fb23a0fe49e65c" dependencies = [ "gettext-sys", "locale_config", @@ -2623,9 +2719,9 @@ dependencies = [ [[package]] name = "gettext-sys" -version = "0.21.3" +version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c63ce2e00f56a206778276704bbe38564c8695249fdc8f354b4ef71c57c3839d" +checksum = "f7b8797f28f2dabfbe2caadb6db4f7fd739e251b5ede0a2ba49e506071edcf67" dependencies = [ "cc", "temp-dir", @@ -2869,7 +2965,7 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "hostname1-zbus" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#7aedc25e3295b95a90eb710f443029d4ec920aa8" +source = "git+https://github.com/pop-os/dbus-settings-bindings#e0d6a04d6ebf6bcede1580721c84a7f01e5ef8bb" dependencies = [ "zbus 4.4.0", ] @@ -2926,8 +3022,8 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "strsim", - "syn 2.0.76", + "strsim 0.11.1", + "syn 2.0.77", "unic-langid", ] @@ -2941,7 +3037,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -2970,7 +3066,7 @@ dependencies = [ [[package]] name = "iced" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "dnd", "iced_accessibility", @@ -2989,7 +3085,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "accesskit", "accesskit_unix", @@ -2998,7 +3094,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "bitflags 2.6.0", "dnd", @@ -3020,7 +3116,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "futures", "iced_core", @@ -3033,7 +3129,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "bitflags 2.6.0", "bytemuck", @@ -3057,7 +3153,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -3069,7 +3165,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "dnd", "iced_accessibility", @@ -3083,7 +3179,7 @@ dependencies = [ [[package]] name = "iced_sctk" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "enum-repr", "float-cmp", @@ -3109,7 +3205,7 @@ dependencies = [ [[package]] name = "iced_style" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "iced_core", "once_cell", @@ -3119,7 +3215,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "bytemuck", "cosmic-text", @@ -3136,7 +3232,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "as-raw-xcb-connection", "bitflags 2.6.0", @@ -3165,7 +3261,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "dnd", "iced_renderer", @@ -3182,7 +3278,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.12.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "dnd", "iced_graphics", @@ -3556,7 +3652,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -3700,9 +3796,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" dependencies = [ "equivalent", "hashbrown 0.14.5", @@ -3775,7 +3871,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -3995,7 +4091,7 @@ checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#0a1922d4b378de23f55b117b07bad23dda0d64d0" +source = "git+https://github.com/pop-os/libcosmic#71cd25c06d230a742ebf660297478b732cf1882b" dependencies = [ "apply", "ashpd 0.9.1", @@ -4397,7 +4493,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4473,7 +4569,7 @@ dependencies = [ "bitflags 2.6.0", "codespan-reporting", "hexf-parse", - "indexmap 2.4.0", + "indexmap 2.5.0", "log", "num-traits", "rustc-hash", @@ -4667,7 +4763,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4739,7 +4835,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4874,7 +4970,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -4920,7 +5016,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5019,7 +5115,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5217,6 +5313,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +dependencies = [ + "bitflags 2.6.0", + "hex", + "lazy_static", + "procfs-core", + "rustix 0.38.35", +] + +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.6.0", + "hex", +] + [[package]] name = "profiling" version = "1.0.15" @@ -5233,7 +5352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd" dependencies = [ "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5585,9 +5704,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f86ae463694029097b846d8f99fd5536740602ae00022c0c50c5600720b2f71" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" dependencies = [ "bytemuck", ] @@ -5665,7 +5784,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.76", + "syn 2.0.77", "walkdir", ] @@ -5829,6 +5948,16 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secure-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548ba8c9ff631f7bb3a64de1e8ad73fe20f6d04090724f2b496ed45314ad7488" +dependencies = [ + "libc", + "zeroize", +] + [[package]] name = "self_cell" version = "0.10.3" @@ -5873,7 +6002,7 @@ checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5882,7 +6011,7 @@ version = "1.0.127" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad" dependencies = [ - "indexmap 2.4.0", + "indexmap 2.5.0", "itoa", "memchr", "ryu", @@ -5897,7 +6026,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -5919,7 +6048,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.4.0", + "indexmap 2.5.0", "serde", "serde_derive", "serde_json", @@ -5933,10 +6062,10 @@ version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -6245,6 +6374,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -6300,9 +6435,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.76" +version = "2.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" dependencies = [ "proc-macro2", "quote", @@ -6318,7 +6453,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -6329,7 +6464,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -6456,7 +6591,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -6514,7 +6649,7 @@ dependencies = [ [[package]] name = "timedate-zbus" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#7aedc25e3295b95a90eb710f443029d4ec920aa8" +source = "git+https://github.com/pop-os/dbus-settings-bindings#e0d6a04d6ebf6bcede1580721c84a7f01e5ef8bb" dependencies = [ "zbus 4.4.0", ] @@ -6618,7 +6753,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -6668,7 +6803,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.4.0", + "indexmap 2.5.0", "toml_datetime", "winnow 0.5.40", ] @@ -6679,7 +6814,7 @@ version = "0.22.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" dependencies = [ - "indexmap 2.4.0", + "indexmap 2.5.0", "serde", "serde_spanned", "toml_datetime", @@ -6705,7 +6840,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -7123,7 +7258,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "wasm-bindgen-shared", ] @@ -7157,7 +7292,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -7393,7 +7528,7 @@ dependencies = [ "bitflags 2.6.0", "cfg_aliases 0.1.1", "codespan-reporting", - "indexmap 2.4.0", + "indexmap 2.5.0", "log", "naga", "once_cell", @@ -7571,7 +7706,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -7582,7 +7717,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -8056,7 +8191,7 @@ checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "synstructure", ] @@ -8158,7 +8293,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "zvariant_utils 2.1.0", ] @@ -8208,7 +8343,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -8228,10 +8363,16 @@ checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + [[package]] name = "zerotrie" version = "0.1.3" @@ -8262,7 +8403,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] [[package]] @@ -8339,7 +8480,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", "zvariant_utils 2.1.0", ] @@ -8362,5 +8503,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.77", ] diff --git a/Cargo.toml b/Cargo.toml index d325b39..4c6e67a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,3 +58,7 @@ cosmic-protocols = { git = "https://github.com/pop-os/cosmic-protocols//", rev = # libcosmic = { path = "../libcosmic" } # cosmic-config = { path = "../libcosmic/cosmic-config" } # cosmic-theme = { path = "../libcosmic/cosmic-theme" } + +# [patch.'https://github.com/pop-os/dbus-settings-bindings'] +# cosmic-dbus-networkmanager = { path = "../dbus-settings-bindings/networkmanager" } +# upower_dbus = { path = "../dbus-settings-bindings/upower" } diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index 234d86f..17e907a 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -6,6 +6,7 @@ license = "GPL-3.0" [dependencies] anyhow = "1.0" +as-result = "0.2.1" ashpd = { version = "0.9", default-features = false, features = ["tokio"] } async-channel = "2.3.1" chrono = "0.4.38" @@ -14,6 +15,7 @@ color-eyre = "0.6.3" cosmic-bg-config.workspace = true cosmic-comp-config.workspace = true cosmic-config.workspace = true +cosmic-dbus-networkmanager = { git = "https://github.com/pop-os/dbus-settings-bindings" } cosmic-panel-config.workspace = true cosmic-randr-shell.workspace = true cosmic-randr.workspace = true @@ -25,6 +27,7 @@ derivative = "2.2.0" derive_setters = "0.1.6" dirs = "5.0.1" downcast-rs = "1.2.1" +eyre = "0.6.12" freedesktop-desktop-entry = "0.7.3" futures = "0.3.30" hostname-validator = "1.1.1" @@ -40,6 +43,7 @@ once_cell = "1.19.0" regex = "1.10.6" ron = "0.8" rust-embed = "8.5.0" +secure-string = "0.3.0" serde = { version = "1.0.208", features = ["derive"] } slab = "0.4.9" slotmap = "1.0.7" @@ -58,7 +62,7 @@ zbus = { version = "4.4.0", features = ["tokio"] } [dependencies.cosmic-settings-subscriptions] git = "https://github.com/pop-os/cosmic-settings-subscriptions" -features = ["pipewire", "pulse"] +features = ["network_manager", "pipewire", "pulse"] [dependencies.icu] version = "1.5.0" diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index 31db049..e3a60df 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -11,7 +11,7 @@ use crate::pages::desktop::{ }, }; use crate::pages::input::{self}; -use crate::pages::{self, display, power, sound, system, time}; +use crate::pages::{self, display, networking, power, sound, system, time}; use crate::subscription::desktop_files; use crate::widget::{page_title, search_header}; use crate::PageCommands; @@ -77,10 +77,13 @@ impl SettingsApp { PageCommands::Time => self.pages.page_id::(), PageCommands::Touchpad => self.pages.page_id::(), PageCommands::Users => self.pages.page_id::(), + PageCommands::Vpn => self.pages.page_id::(), PageCommands::Wallpaper => self.pages.page_id::(), PageCommands::WindowManagement => { self.pages.page_id::() } + PageCommands::Wired => self.pages.page_id::(), + PageCommands::Wireless => self.pages.page_id::(), PageCommands::Workspaces => self.pages.page_id::(), } } @@ -141,6 +144,7 @@ impl cosmic::Application for SettingsApp { search_selections: Vec::default(), }; + app.insert_page::(); let desktop_id = app.insert_page::().id(); app.insert_page::(); app.insert_page::(); @@ -452,9 +456,27 @@ impl cosmic::Application for SettingsApp { page::update!(self.pages, message, power::Page); } + crate::pages::Message::Vpn(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + + crate::pages::Message::WiFi(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + crate::pages::Message::WindowManagement(message) => { page::update!(self.pages, message, desktop::window_management::Page); } + + crate::pages::Message::Wired(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } }, Message::OutputAdded(info, output) => { @@ -730,7 +752,7 @@ impl SettingsApp { custom_header.map(Message::from) } else if let Some(parent) = page_info.parent { let page_header = crate::widget::sub_page_header( - page_info.title.as_str(), + page.title().unwrap_or_else(|| page_info.title.as_str()), self.pages.info[parent].title.as_str(), Message::Page(parent), ); diff --git a/cosmic-settings/src/main.rs b/cosmic-settings/src/main.rs index f4b7db8..008126d 100644 --- a/cosmic-settings/src/main.rs +++ b/cosmic-settings/src/main.rs @@ -78,10 +78,16 @@ pub enum PageCommands { Touchpad, /// Users settings page Users, + /// VPN settings page + Vpn, /// Wallpaper settings page Wallpaper, /// Window management settings page WindowManagement, + /// Wired settings page + Wired, + /// WiFi settings page + Wireless, /// Workspaces settings page Workspaces, } diff --git a/cosmic-settings/src/pages/desktop/wallpaper/mod.rs b/cosmic-settings/src/pages/desktop/wallpaper/mod.rs index 0a91c12..a6f87d1 100644 --- a/cosmic-settings/src/pages/desktop/wallpaper/mod.rs +++ b/cosmic-settings/src/pages/desktop/wallpaper/mod.rs @@ -24,18 +24,11 @@ use cosmic::{ }, }; use cosmic::{ - iced::{wayland::actions::window::SctkWindowSettings, window, Color, Length}, + iced::{Color, Length}, prelude::CollectionWidget, }; -use cosmic::{ - iced_core::Alignment, - iced_sctk::commands::window::{close_window, get_window}, - widget::icon, -}; -use cosmic::{ - iced_core::{alignment, layout}, - iced_runtime::core::image::Handle as ImageHandle, -}; +use cosmic::{iced_core::alignment, iced_runtime::core::image::Handle as ImageHandle}; +use cosmic::{iced_core::Alignment, widget::icon}; use cosmic::{ widget::{color_picker::ColorPickerUpdate, ColorPickerModel}, Element, diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index d79413a..600e756 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -6,6 +6,7 @@ use cosmic_settings_page::Entity; pub mod desktop; pub mod display; pub mod input; +pub mod networking; pub mod power; pub mod sound; pub mod system; @@ -37,7 +38,10 @@ pub enum Message { Sound(sound::Message), SystemShortcuts(input::keyboard::shortcuts::ShortcutMessage), TilingShortcuts(input::keyboard::shortcuts::ShortcutMessage), + Vpn(networking::vpn::Message), + WiFi(networking::wifi::Message), WindowManagement(desktop::window_management::Message), + Wired(networking::wired::Message), } impl From for crate::Message { diff --git a/cosmic-settings/src/pages/networking/accounts.rs b/cosmic-settings/src/pages/networking/accounts.rs deleted file mode 100644 index 6cc790d..0000000 --- a/cosmic-settings/src/pages/networking/accounts.rs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: GPL-3.0-only - -use cosmic_settings_page as page; - -pub fn info() -> page::Info { - page::Info::new("online-accounts", "goa-panel-symbolic") - .title(fl!("online-accounts")) - .description(fl!("online-accounts", "desc")) -} diff --git a/cosmic-settings/src/pages/networking/mod.rs b/cosmic-settings/src/pages/networking/mod.rs index df4d10d..ee1af44 100644 --- a/cosmic-settings/src/pages/networking/mod.rs +++ b/cosmic-settings/src/pages/networking/mod.rs @@ -1,5 +1,62 @@ -// Copyright 2023 System76 +// Copyright 2024 System76 // SPDX-License-Identifier: GPL-3.0-only -pub mod accounts; +pub mod vpn; +pub mod wifi; pub mod wired; + +use std::{ffi::OsStr, io, process::ExitStatus}; + +use cosmic_settings_page as page; + +static NM_CONNECTION_EDITOR: &str = "nm-connection-editor"; + +#[derive(Debug, Default)] +pub struct Page; + +impl page::Page for Page { + fn info(&self) -> cosmic_settings_page::Info { + page::Info::new( + "network-and-wireless", + "preferences-network-and-wireless-symbolic", + ) + .title(fl!("network-and-wireless")) + } +} + +impl page::AutoBind for Page { + fn sub_pages( + page: cosmic_settings_page::Insert, + ) -> cosmic_settings_page::Insert { + page.sub_page::() + .sub_page::() + .sub_page::() + } +} + +async fn nm_add_vpn_file>(type_: &str, path: P) -> io::Result { + tokio::process::Command::new("nmcli") + .args(["connection", "import", "type", type_, "file"]) + .arg(path) + .status() + .await +} + +async fn nm_add_wired() -> io::Result { + nm_connection_editor(&["--type=802-3-ethernet", "-c"]).await +} + +async fn nm_add_wifi() -> io::Result { + nm_connection_editor(&["--type=802-11-wireless", "-c"]).await +} + +async fn nm_edit_connection(uuid: &str) -> io::Result { + nm_connection_editor(&[&["--edit=", uuid].concat()]).await +} + +async fn nm_connection_editor(args: &[&str]) -> io::Result { + tokio::process::Command::new(NM_CONNECTION_EDITOR) + .args(args) + .status() + .await +} diff --git a/cosmic-settings/src/pages/networking/vpn/mod.rs b/cosmic-settings/src/pages/networking/vpn/mod.rs new file mode 100644 index 0000000..6c173cb --- /dev/null +++ b/cosmic-settings/src/pages/networking/vpn/mod.rs @@ -0,0 +1,877 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: GPL-3.0-only + +mod nmcli; + +use std::sync::Arc; + +use anyhow::Context; +use ashpd::desktop::file_chooser::FileFilter; +use cosmic::{ + iced::{alignment, Length}, + iced_core::text::Wrap, + prelude::CollectionWidget, + widget::{self, icon}, + Apply, Command, Element, +}; +use cosmic_settings_page::{self as page, section, Section}; +use cosmic_settings_subscriptions::network_manager::{ + self, current_networks::ActiveConnectionInfo, NetworkManagerState, UUID, +}; +use futures::{FutureExt, StreamExt}; +use indexmap::IndexMap; +use secure_string::SecureString; +use slab::Slab; + +pub type ConnectionId = Arc; +pub type InterfaceId = String; + +#[derive(Clone, Debug)] +pub enum Message { + /// Activate a connection + Activate(ConnectionId), + /// Add a network connection + AddNetwork, + /// Cancels an active dialog. + CancelDialog, + /// Connect to a VPN with the given username and password + ConnectWithPassword, + /// Deactivate a connection. + Deactivate(ConnectionId), + /// An error occurred. + Error(String), + /// Update the list of known connections. + KnownConnections(IndexMap), + /// An update from the network manager daemon + NetworkManager(network_manager::Event), + /// Successfully connected to the system dbus. + NetworkManagerConnect( + ( + zbus::Connection, + tokio::sync::mpsc::Sender, + ), + ), + /// Updates the password text input + PasswordUpdate(SecureString), + /// Refresh devices and their connection profiles + Refresh, + /// Create a dialog to ask for confirmation of removal. + RemoveProfileRequest(ConnectionId), + /// Remove a connection profile + RemoveProfile(ConnectionId), + /// Opens settings page for the access point. + Settings(ConnectionId), + /// Toggles visibility of password input. + TogglePasswordVisibility, + /// Update NetworkManagerState + UpdateState(NetworkManagerState), + /// Update the devices lists + UpdateDevices(Vec), + /// Updates the username text input + UsernameUpdate(String), + /// Display more options for an access point + ViewMore(Option), +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Vpn(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Vpn(message) + } +} + +#[derive(Clone, Debug, Default)] +pub struct VpnConnectionSettings { + id: String, + username: Option, + connection_type: Option, + password_flag: Option, +} + +impl VpnConnectionSettings { + fn password_flag(&self) -> Option { + self.connection_type + .as_ref() + .map_or(false, |ct| match ct { + ConnectionType::Password => true, + }) + .then(|| self.password_flag) + .flatten() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ConnectionType { + Password, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum PasswordFlag { + /// The system is responsible for providing and storing this secret. + None = 0, + /// A user-session secret agent is responsible for providing and storing + /// this secret; when it is required, agents will be asked to provide it. + AgentOwned = 1, + /// This secret should not be saved but should be requested from the user + /// each time it is required. This flag should be used for One-Time-Pad + /// secrets, PIN codes from hardware tokens, or if the user simply does not + /// want to save the secret. + NotSaved = 2, + /// in some situations it cannot be automatically determined that a secret is required or not. This flag hints that the secret is not required and should not be requested from the user. + NotRequired = 4, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum VpnDialog { + Password { + id: String, + uuid: Arc, + username: String, + password: SecureString, + password_hidden: bool, + }, + RemoveProfile(ConnectionId), +} + +#[derive(Debug)] +pub struct NmState { + conn: zbus::Connection, + sender: futures::channel::mpsc::UnboundedSender, + active_conns: Vec, + devices: Vec, +} + +#[derive(Debug, Default)] +pub struct Page { + nm_task: Option>, + nm_state: Option, + dialog: Option, + view_more_popup: Option, + known_connections: IndexMap, + /// Withhold device update if the view more popup is shown. + withheld_devices: Option>, + /// Withhold active connections update if the view more popup is shown. + withheld_active_conns: Option>, +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn info(&self) -> cosmic_settings_page::Info { + page::Info::new("vpn", "preferences-vpn-symbolic") + .title(fl!("vpn")) + .description(fl!("connections-and-profiles", variant = "vpn")) + } + + fn content( + &self, + sections: &mut slotmap::SlotMap>, + ) -> Option { + Some(vec![sections.insert(devices_view())]) + } + + fn dialog(&self) -> Option> { + self.dialog.as_ref().map(|dialog| match dialog { + VpnDialog::Password { + username, + password, + password_hidden, + .. + } => { + let username = widget::text_input(fl!("username"), username.as_str()) + .on_input(Message::UsernameUpdate); + + let password = widget::text_input::secure_input( + fl!("password"), + password.unsecure(), + Some(Message::TogglePasswordVisibility), + *password_hidden, + ) + .on_input(|input| Message::PasswordUpdate(SecureString::from(input))) + .on_submit(Message::ConnectWithPassword); + + let controls = widget::column::with_capacity(2) + .spacing(12) + .push(username) + .push(password) + .apply(Element::from); + + let primary_action = widget::button::suggested(fl!("connect")) + .on_press(Message::ConnectWithPassword); + + let secondary_action = + widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog); + + widget::dialog(fl!("auth-dialog")) + .icon(icon::from_name("network-vpn-symbolic").size(64)) + .body(fl!("auth-dialog", "vpn-description")) + .control(controls) + .primary_action(primary_action) + .secondary_action(secondary_action) + .apply(Element::from) + .map(crate::pages::Message::Vpn) + } + + VpnDialog::RemoveProfile(uuid) => { + let primary_action = widget::button::destructive(fl!("remove")) + .on_press(Message::RemoveProfile(uuid.clone())); + + let secondary_action = + widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog); + + widget::dialog(fl!("remove-connection-dialog")) + .icon(icon::from_name("dialog-information").size(64)) + .body(fl!("remove-connection-dialog", "vpn-description")) + .primary_action(primary_action) + .secondary_action(secondary_action) + .apply(Element::from) + .map(crate::pages::Message::Vpn) + } + }) + } + + fn header_view(&self) -> Option> { + Some( + widget::button::standard(fl!("add-network")) + .trailing_icon(icon::from_name("window-pop-out-symbolic")) + .on_press(Message::AddNetwork) + .apply(widget::container) + .width(Length::Fill) + .align_x(alignment::Horizontal::Right) + .apply(Element::from) + .map(crate::pages::Message::Vpn), + ) + } + + fn on_enter( + &mut self, + _page: cosmic_settings_page::Entity, + sender: tokio::sync::mpsc::Sender, + ) -> cosmic::Command { + if self.nm_task.is_none() { + return cosmic::command::future(async move { + zbus::Connection::system() + .await + .context("failed to create system dbus connection") + .map_or_else( + |why| Message::Error(why.to_string()), + |conn| Message::NetworkManagerConnect((conn, sender.clone())), + ) + }); + } + + Command::none() + } + + fn on_leave(&mut self) -> Command { + self.view_more_popup = None; + self.nm_state = None; + self.withheld_active_conns = None; + self.withheld_devices = None; + self.dialog = None; + + if let Some(cancel) = self.nm_task.take() { + _ = cancel.send(()); + } + + Command::none() + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> Command { + match message { + Message::NetworkManager(network_manager::Event::RequestResponse { + req, + state, + success, + }) => { + if !success { + tracing::error!(request = ?req, "network-manager request failed"); + } + + if let Some(NmState { ref conn, .. }) = self.nm_state { + let conn = conn.clone(); + self.update_active_conns(state); + return cosmic::command::batch(vec![ + connection_settings(conn.clone()), + update_devices(conn), + ]); + } + } + + Message::KnownConnections(connections) => { + self.known_connections = connections; + } + + Message::UpdateDevices(devices) => { + self.update_devices(devices); + } + + Message::UpdateState(state) => { + self.update_active_conns(state); + } + + Message::NetworkManager( + network_manager::Event::ActiveConns | network_manager::Event::Devices, + ) => { + if let Some(NmState { ref conn, .. }) = self.nm_state { + return cosmic::command::batch(vec![ + update_state(conn.clone()), + update_devices(conn.clone()), + connection_settings(conn.clone()), + ]); + } + } + + Message::NetworkManager(network_manager::Event::Init { + conn, + sender, + state, + }) => { + self.nm_state = Some(NmState { + conn: conn.clone(), + sender, + devices: Vec::new(), + active_conns: state + .active_conns + .into_iter() + .filter(|info| matches!(info, ActiveConnectionInfo::Vpn { .. })) + .collect(), + }); + + return cosmic::command::batch(vec![ + connection_settings(conn.clone()), + update_devices(conn), + ]); + } + + Message::NetworkManager(_event) => (), + + Message::AddNetwork => return add_network(), + + Message::Activate(uuid) => { + self.close_popup_and_apply_updates(); + + if let Some(settings) = self.known_connections.get(&uuid) { + match settings.password_flag() { + Some(PasswordFlag::NotSaved | PasswordFlag::AgentOwned) => { + self.view_more_popup = None; + self.dialog = Some(VpnDialog::Password { + id: settings.id.clone(), + uuid: uuid.clone(), + username: settings.username.clone().unwrap_or_default(), + password: SecureString::from(""), + password_hidden: true, + }); + } + + _ => { + let connection_name = settings.id.clone(); + return cosmic::command::future(async move { + if let Err(why) = nmcli::connect(&connection_name).await { + return Message::Error(format!( + "failed to connect to VPN: {why}" + )); + } + + Message::Refresh + }); + } + } + } + } + + Message::Deactivate(uuid) => { + self.close_popup_and_apply_updates(); + if let Some(NmState { ref sender, .. }) = self.nm_state { + _ = sender.unbounded_send(network_manager::Request::Deactivate(uuid)); + } + } + + Message::RemoveProfileRequest(uuid) => { + self.view_more_popup = None; + self.dialog = Some(VpnDialog::RemoveProfile(uuid)); + } + + Message::RemoveProfile(uuid) => { + self.dialog = None; + self.close_popup_and_apply_updates(); + if let Some(NmState { ref sender, .. }) = self.nm_state { + _ = sender.unbounded_send(network_manager::Request::Remove(uuid)); + } + } + + Message::ViewMore(uuid) => { + self.view_more_popup = uuid; + if self.view_more_popup.is_none() { + self.close_popup_and_apply_updates(); + } + } + + Message::Settings(uuid) => { + self.close_popup_and_apply_updates(); + + return cosmic::command::future(async move { + super::nm_edit_connection(uuid.as_ref()) + .then(|res| async move { + match res.context("failed to open connection editor") { + Ok(_) => Message::Refresh, + Err(why) => Message::Error(why.to_string()), + } + }) + .await + }); + } + + Message::Refresh => { + if let Some(NmState { ref conn, .. }) = self.nm_state { + return cosmic::command::batch(vec![ + update_state(conn.clone()), + update_devices(conn.clone()), + connection_settings(conn.clone()), + ]); + } + } + + Message::PasswordUpdate(pass) => { + if let Some(VpnDialog::Password { + ref mut password, .. + }) = self.dialog + { + *password = pass; + } + } + + Message::ConnectWithPassword => { + let Some(dialog) = self.dialog.take() else { + return Command::none(); + }; + + if let VpnDialog::Password { + id, + username, + password, + .. + } = dialog + { + return self + .activate_with_password(id, username, password) + .map(crate::app::Message::from); + } + } + + Message::UsernameUpdate(user) => { + if let Some(VpnDialog::Password { + ref mut username, .. + }) = self.dialog + { + *username = user; + } + } + + Message::CancelDialog => { + self.dialog = None; + } + + Message::TogglePasswordVisibility => { + if let Some(VpnDialog::Password { + ref mut password_hidden, + .. + }) = self.dialog + { + *password_hidden = !*password_hidden; + } + } + + Message::Error(why) => { + tracing::error!(why, "error in VPN settings page"); + } + + Message::NetworkManagerConnect((conn, output)) => { + self.connect(conn.clone(), output); + } + } + + Command::none() + } + + fn activate_with_password( + &mut self, + connection_name: String, + username: String, + password: SecureString, + ) -> Command { + cosmic::command::future(async move { + if let Err(why) = nmcli::set_username(&connection_name, &username).await { + return Message::Error(format!("failed to set VPN username: {why}")); + } + + if let Err(why) = nmcli::set_password_flags_none(&connection_name).await { + return Message::Error(format!( + "failed to call nmcli to set VPN password-flags parameter: {why}" + )); + } + + if let Err(why) = nmcli::set_password(&connection_name, password.unsecure()).await { + return Message::Error(format!("failed to call nmcli to set VPN password: {why}")); + } + + if let Err(why) = nmcli::connect(&connection_name).await { + return Message::Error(format!("failed to connect to VPN: {why}")); + } + + Message::Refresh + }) + } + + fn connect( + &mut self, + conn: zbus::Connection, + sender: tokio::sync::mpsc::Sender, + ) { + if self.nm_task.is_none() { + self.nm_task = Some(crate::utils::forward_event_loop( + sender, + |event| crate::pages::Message::Vpn(Message::NetworkManager(event)), + move |tx| async move { + futures::join!( + network_manager::watch(conn.clone(), tx.clone()), + network_manager::active_conns::watch(conn.clone(), tx.clone()), + network_manager::devices::watch(conn, true, tx) + ); + }, + )); + } + } + + /// Closes the view more popup and applies any withheld updates. + fn close_popup_and_apply_updates(&mut self) { + self.view_more_popup = None; + if let Some(ref mut nm_state) = self.nm_state { + if let Some(active_conns) = self.withheld_active_conns.take() { + nm_state.active_conns = active_conns; + } + + if let Some(devices) = self.withheld_devices.take() { + nm_state.devices = devices; + } + } + } + + /// Withholds updates if the view more popup is displayed. + fn update_devices(&mut self, devices: Vec) { + if let Some(ref mut nm_state) = self.nm_state { + if self.view_more_popup.is_some() { + self.withheld_devices = Some(devices); + } else { + nm_state.devices = devices; + } + } + } + + /// Withholds updates if the view more popup is displayed. + fn update_active_conns(&mut self, state: NetworkManagerState) { + if let Some(ref mut nm_state) = self.nm_state { + let conns = state + .active_conns + .into_iter() + .filter(|info| matches!(info, ActiveConnectionInfo::Vpn { .. })) + .collect(); + + if self.view_more_popup.is_some() { + self.withheld_active_conns = Some(conns); + } else { + nm_state.active_conns = conns; + } + } + } +} + +fn devices_view() -> Section { + crate::slab!(descriptions { + vpn_conns_txt = fl!("vpn", "connections"); + remove_txt = fl!("vpn", "remove"); + connect_txt = fl!("connect"); + connected_txt = fl!("connected"); + settings_txt = fl!("settings"); + disconnect_txt = fl!("disconnect"); + }); + + Section::default() + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let Some(NmState { + ref active_conns, .. + }) = page.nm_state + else { + return cosmic::widget::column().into(); + }; + + let theme = cosmic::theme::active(); + let spacing = &theme.cosmic().spacing; + + let mut view = widget::column::with_capacity(4); + + let vpn_connections = + widget::settings::view_section(§ion.descriptions[vpn_conns_txt]); + + if page.known_connections.is_empty() { + view = view.push(vpn_connections.add(widget::settings::item_row(vec![ + widget::text::body(fl!("no-vpn")).into(), + ]))); + } else { + let known_networks = page.known_connections.iter().fold( + vpn_connections, + |networks, (uuid, connection)| { + let is_connected = active_conns.iter().any(|conn| match conn { + ActiveConnectionInfo::Vpn { name, .. } => { + name.as_str() == connection.id.as_str() + } + + _ => false, + }); + + let (connect_txt, connect_msg) = if is_connected { + (§ion.descriptions[connected_txt], None) + } else { + ( + §ion.descriptions[connect_txt], + Some(Message::Activate(uuid.clone())), + ) + }; + + let identifier = + widget::text::body(connection.id.as_str()).wrap(Wrap::Glyph); + + let connect: Element<'_, Message> = if let Some(msg) = connect_msg { + widget::button::text(connect_txt).on_press(msg).into() + } else { + widget::text::body(connect_txt) + .vertical_alignment(alignment::Vertical::Center) + .into() + }; + + let view_more_button = + widget::button::icon(widget::icon::from_name("view-more-symbolic")); + + let view_more: Option> = if page + .view_more_popup + .as_deref() + .map_or(false, |id| id == uuid.as_ref()) + { + widget::popover(view_more_button.on_press(Message::ViewMore(None))) + .position(widget::popover::Position::Bottom) + .on_close(Message::ViewMore(None)) + .popup({ + widget::column() + .push_maybe(is_connected.then(|| { + popup_button( + Message::Deactivate(uuid.clone()), + §ion.descriptions[disconnect_txt], + ) + })) + .push(popup_button( + Message::Settings(uuid.clone()), + §ion.descriptions[settings_txt], + )) + .push(popup_button( + Message::RemoveProfileRequest(uuid.clone()), + §ion.descriptions[remove_txt], + )) + .width(Length::Fixed(200.0)) + .apply(widget::container) + .style(cosmic::style::Container::Dialog) + }) + .apply(|e| Some(Element::from(e))) + } else { + view_more_button + .on_press(Message::ViewMore(Some(uuid.clone()))) + .apply(|e| Some(Element::from(e))) + }; + + let controls = widget::row::with_capacity(2) + .push(connect) + .push_maybe(view_more) + .align_items(alignment::Alignment::Center) + .spacing(spacing.space_xxs); + + let widget = widget::settings::item_row(vec![ + identifier.into(), + widget::horizontal_space(Length::Fill).into(), + controls.into(), + ]); + + networks.add(widget) + }, + ); + + view = view.push(known_networks); + } + + view.spacing(spacing.space_l) + .apply(Element::from) + .map(crate::pages::Message::Vpn) + }) +} + +fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> { + widget::text::body(text) + .vertical_alignment(alignment::Vertical::Center) + .apply(widget::button) + .padding([4, 16]) + .width(Length::Fill) + .style(cosmic::theme::Button::MenuItem) + .on_press(message) + .into() +} + +fn update_state(conn: zbus::Connection) -> Command { + cosmic::command::future(async move { + match NetworkManagerState::new(&conn).await { + Ok(state) => Message::UpdateState(state), + Err(why) => Message::Error(why.to_string()), + } + }) +} + +fn update_devices(conn: zbus::Connection) -> Command { + cosmic::command::future(async move { + let filter = + |device_type| matches!(device_type, network_manager::devices::DeviceType::WireGuard); + + match network_manager::devices::list(&conn, filter).await { + Ok(devices) => Message::UpdateDevices(devices), + Err(why) => Message::Error(why.to_string()), + } + }) +} + +fn add_network() -> Command { + let Some(dir) = dirs::download_dir().or_else(dirs::home_dir) else { + return Command::none(); + }; + + cosmic::dialog::file_chooser::open::Dialog::new() + .directory(dir) + .title(fl!("vpn", "select-file")) + .filter( + FileFilter::new("OpenVPN") + .mimetype("application/x-openvpn-profile") + .glob("*.ovpn"), + ) + .open_file() + .then(|result| async move { + match result { + Ok(response) => { + _ = super::nm_add_vpn_file("openvpn", response.url().path()).await; + Message::Refresh + } + Err(why) => { + return Message::Error(why.to_string()); + } + } + }) + .apply(cosmic::command::future) +} + +fn connection_settings(conn: zbus::Connection) -> Command { + let settings = async move { + let settings = network_manager::dbus::settings::NetworkManagerSettings::new(&conn).await?; + + _ = settings.load_connections(&[]).await; + + let settings = settings + // Get a list of known connections. + .list_connections() + .await? + // Prepare for wrapping in a concurrent stream. + .into_iter() + .map(|conn| async move { conn }) + // Create a concurrent stream for each connection. + .apply(futures::stream::FuturesOrdered::from_iter) + // Concurrently fetch settings for each connection, and filter for VPN. + .filter_map(|conn| async move { + let settings = conn.get_settings().await.ok()?; + + let (connection, vpn) = settings.get("connection").zip(settings.get("vpn"))?; + + if connection.get("type")?.downcast_ref::().ok()? != "vpn" { + return None; + } + + let id = connection.get("id")?.downcast_ref::().ok()?; + let uuid = connection.get("uuid")?.downcast_ref::().ok()?; + + let (username, connection_type, password_flag) = vpn + .get("data") + .and_then(|data| data.downcast_ref::().ok()) + .map(|dict| { + let (mut connection_type, mut password_flag) = (None, None); + + let username = dict + .get::(&String::from("username")) + .ok() + .flatten() + .filter(|value| !value.is_empty()); + + if let Some("password") = dict + .get::(&String::from("connection-type")) + .ok() + .flatten() + .as_deref() + { + connection_type = Some(ConnectionType::Password); + + password_flag = dict + .get::(&String::from("password-flags")) + .ok() + .flatten() + .and_then(|value| match value.as_str() { + "0" => Some(PasswordFlag::None), + "1" => Some(PasswordFlag::AgentOwned), + "2" => Some(PasswordFlag::NotSaved), + "4" => Some(PasswordFlag::NotRequired), + _ => None, + }); + } + + (username, connection_type, password_flag) + }) + .unwrap_or_default(); + + Some(( + Arc::from(uuid), + VpnConnectionSettings { + id, + connection_type, + password_flag, + username, + }, + )) + }) + // Reduce the settings list into + .fold(IndexMap::new(), |mut set, (uuid, data)| async move { + set.insert(uuid, data); + set + }) + .await; + + Ok::<_, zbus::Error>(settings) + }; + + cosmic::command::future(async move { + settings + .await + .context("failed to get connection settings") + .map_or_else( + |why| Message::Error(why.to_string()), + Message::KnownConnections, + ) + }) +} diff --git a/cosmic-settings/src/pages/networking/vpn/nmcli.rs b/cosmic-settings/src/pages/networking/vpn/nmcli.rs new file mode 100644 index 0000000..cde91d3 --- /dev/null +++ b/cosmic-settings/src/pages/networking/vpn/nmcli.rs @@ -0,0 +1,49 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use as_result::IntoResult; +use std::io; + +pub async fn set_username(connection_name: &str, username: &str) -> io::Result<()> { + tokio::process::Command::new("nmcli") + .args(&["con", "mod", connection_name, "vpn.user-name", username]) + .status() + .await + .and_then(IntoResult::into_result) +} + +pub async fn set_password_flags_none(connection_name: &str) -> io::Result<()> { + tokio::process::Command::new("nmcli") + .args(&[ + "con", + "mod", + connection_name, + "+vpn.data", + "password-flags=0", + ]) + .status() + .await + .and_then(IntoResult::into_result) +} + +pub async fn set_password(connection_name: &str, password: &str) -> io::Result<()> { + tokio::process::Command::new("nmcli") + .args(&[ + "con", + "mod", + &connection_name, + "vpn.secrets", + &format!("password={password}"), + ]) + .status() + .await + .and_then(IntoResult::into_result) +} + +pub async fn connect(connection_name: &str) -> io::Result<()> { + tokio::process::Command::new("nmcli") + .args(&["con", "up", &connection_name]) + .status() + .await + .and_then(IntoResult::into_result) +} diff --git a/cosmic-settings/src/pages/networking/wifi.rs b/cosmic-settings/src/pages/networking/wifi.rs new file mode 100644 index 0000000..354df55 --- /dev/null +++ b/cosmic-settings/src/pages/networking/wifi.rs @@ -0,0 +1,787 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Context; +use cosmic::{ + iced::{alignment, Length}, + iced_core::text::Wrap, + prelude::CollectionWidget, + widget::{self, icon}, + Apply, Command, Element, +}; +use cosmic_settings_page::{self as page, section, Section}; +use cosmic_settings_subscriptions::network_manager::{ + self, available_wifi::AccessPoint, current_networks::ActiveConnectionInfo, NetworkManagerState, +}; +use futures::StreamExt; +use secure_string::SecureString; +use slab::Slab; + +#[derive(Clone, Debug)] +pub enum Message { + /// Add a network connection with nm-connection-editor + AddNetwork, + /// Cancels a dialog. + CancelDialog, + /// Connect to a WiFi network access point. + Connect(network_manager::SSID), + /// Connect with a password + ConnectWithPassword, + /// Settings for known connections. + ConnectionSettings(BTreeMap, Box>), + /// Disconnect from an access point. + Disconnect(network_manager::SSID), + /// An error occurred. + Error(String), + /// Create a dialog to ask for confirmation on forgetting a connection. + ForgetRequest(network_manager::SSID), + /// Forget a known access point. + Forget(network_manager::SSID), + /// An update from the network manager daemon + NetworkManager(network_manager::Event), + /// Successfully connected to the system dbus. + NetworkManagerConnect( + ( + zbus::Connection, + tokio::sync::mpsc::Sender, + ), + ), + /// Request an auth dialog + PasswordRequest(network_manager::SSID), + /// Update the password from the dialog + PasswordUpdate(SecureString), + /// Opens settings page for the access point. + Settings(network_manager::SSID), + /// Toggles visibility of the password input + TogglePasswordVisibility, + /// Update NetworkManagerState + UpdateState(NetworkManagerState), + /// Update the devices lists + UpdateDevices(Vec), + /// Display more options for an access point + ViewMore(Option), + /// Toggle WiFi access + WiFiEnable(bool), +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::WiFi(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::WiFi(message) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum WiFiDialog { + Forget(network_manager::SSID), + Password { + ssid: network_manager::SSID, + password: SecureString, + password_hidden: bool, + }, +} + +#[derive(Debug, Default)] +pub struct Page { + nm_task: Option>, + nm_state: Option, + dialog: Option, + view_more_popup: Option, + connecting: BTreeSet, + ssid_to_uuid: BTreeMap, Box>, + /// Withhold device update if the view more popup is shown. + withheld_devices: Option>, + /// Withhold state update if the view more popup is shown. + withheld_state: Option, +} + +#[derive(Debug)] +pub struct NmState { + conn: zbus::Connection, + sender: futures::channel::mpsc::UnboundedSender, + state: network_manager::NetworkManagerState, + devices: Vec, +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn info(&self) -> cosmic_settings_page::Info { + page::Info::new("wifi", "preferences-wireless-symbolic") + .title(fl!("wifi")) + .description(fl!("connections-and-profiles", variant = "wifi")) + } + + fn content( + &self, + sections: &mut slotmap::SlotMap>, + ) -> Option { + Some(vec![sections.insert(devices_view())]) + } + + fn dialog(&self) -> Option> { + self.dialog.as_ref().map(|dialog| match dialog { + WiFiDialog::Password { + password, + password_hidden, + .. + } => { + let password = widget::text_input::secure_input( + fl!("password"), + password.unsecure(), + Some(Message::TogglePasswordVisibility), + *password_hidden, + ) + .on_input(|input| Message::PasswordUpdate(SecureString::from(input))) + .on_submit(Message::ConnectWithPassword); + + let primary_action = widget::button::suggested(fl!("connect")) + .on_press(Message::ConnectWithPassword); + + let secondary_action = + widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog); + + widget::dialog(fl!("auth-dialog")) + .icon(icon::from_name("preferences-wireless-symbolic").size(64)) + .body(fl!("auth-dialog", "wifi-description")) + .control(password) + .primary_action(primary_action) + .secondary_action(secondary_action) + .apply(Element::from) + .map(crate::pages::Message::WiFi) + } + + WiFiDialog::Forget(ssid) => { + let primary_action = widget::button::destructive(fl!("forget")) + .on_press(Message::Forget(ssid.clone())); + + let secondary_action = + widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog); + + widget::dialog(fl!("forget-dialog")) + .icon(icon::from_name("dialog-information").size(64)) + .body(fl!("forget-dialog", "description")) + .primary_action(primary_action) + .secondary_action(secondary_action) + .apply(Element::from) + .map(crate::pages::Message::WiFi) + } + }) + } + + fn header_view(&self) -> Option> { + Some( + widget::button::standard(fl!("add-network")) + .trailing_icon(icon::from_name("window-pop-out-symbolic")) + .on_press(Message::AddNetwork) + .apply(widget::container) + .width(Length::Fill) + .align_x(alignment::Horizontal::Right) + .apply(Element::from) + .map(crate::pages::Message::WiFi), + ) + } + + fn on_enter( + &mut self, + _page: cosmic_settings_page::Entity, + sender: tokio::sync::mpsc::Sender, + ) -> cosmic::Command { + if self.nm_task.is_none() { + return cosmic::command::future(async move { + zbus::Connection::system() + .await + .context("failed to create system dbus connection") + .map_or_else( + |why| Message::Error(why.to_string()), + |conn| Message::NetworkManagerConnect((conn, sender.clone())), + ) + .apply(crate::pages::Message::WiFi) + }); + } + + Command::none() + } + + fn on_leave(&mut self) -> Command { + self.view_more_popup = None; + self.nm_state = None; + self.ssid_to_uuid.clear(); + self.connecting.clear(); + self.withheld_state = None; + self.withheld_devices = None; + + if let Some(cancel) = self.nm_task.take() { + _ = cancel.send(()); + } + + Command::none() + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> Command { + match message { + Message::NetworkManager(network_manager::Event::RequestResponse { + req, + state, + success, + }) => { + if !success { + tracing::error!(request = ?req, "network-manager request failed"); + } + + match req { + network_manager::Request::Password(ssid, _) => { + if success { + self.connecting.remove(&ssid); + } else { + // Request to retry + self.dialog = Some(WiFiDialog::Password { + ssid, + password: SecureString::from(""), + password_hidden: true, + }); + } + } + + network_manager::Request::SelectAccessPoint(ssid) => { + self.connecting.remove(&ssid); + } + + _ => (), + } + + self.update_state(state); + + if let Some(NmState { ref conn, .. }) = self.nm_state { + return update_devices(conn.clone()); + } + } + + Message::UpdateDevices(devices) => { + self.update_devices(devices); + } + + Message::UpdateState(state) => { + self.update_state(state); + + if let Some(NmState { ref conn, .. }) = self.nm_state { + return connection_settings(conn.clone()); + } + } + + Message::NetworkManager( + network_manager::Event::ActiveConns + | network_manager::Event::Devices + | network_manager::Event::WiFiEnabled(_) + | network_manager::Event::WirelessAccessPoints, + ) => { + if let Some(NmState { ref conn, .. }) = self.nm_state { + return cosmic::command::batch(vec![ + update_state(conn.clone()), + update_devices(conn.clone()), + ]); + } + } + + Message::ConnectionSettings(settings) => { + self.ssid_to_uuid = settings; + } + + Message::NetworkManager(network_manager::Event::Init { + conn, + sender, + state, + }) => { + self.nm_state = Some(NmState { + conn: conn.clone(), + sender, + state, + devices: Vec::new(), + }); + + return update_devices(conn); + } + + Message::AddNetwork => { + tokio::task::spawn(super::nm_add_wifi()); + } + + Message::Connect(ssid) => { + if let Some(nm) = self.nm_state.as_mut() { + self.connecting.insert(ssid.clone()); + _ = nm + .sender + .unbounded_send(network_manager::Request::SelectAccessPoint(ssid)); + } + } + + Message::PasswordRequest(ssid) => { + self.dialog = Some(WiFiDialog::Password { + ssid, + password: SecureString::from(""), + password_hidden: true, + }); + } + + Message::PasswordUpdate(pass) => { + if let Some(WiFiDialog::Password { + ref mut password, .. + }) = self.dialog + { + *password = pass; + } + } + + Message::ConnectWithPassword => { + let Some(dialog) = self.dialog.take() else { + return Command::none(); + }; + + if let WiFiDialog::Password { ssid, password, .. } = dialog { + if let Some(nm) = self.nm_state.as_mut() { + self.connecting.insert(ssid.clone()); + _ = nm + .sender + .unbounded_send(network_manager::Request::Password(ssid, password)); + } + } + } + + Message::TogglePasswordVisibility => { + if let Some(WiFiDialog::Password { + ref mut password_hidden, + .. + }) = self.dialog + { + *password_hidden = !*password_hidden; + } + } + + Message::ViewMore(ssid) => { + self.view_more_popup = ssid; + if self.view_more_popup.is_none() { + self.close_popup_and_apply_updates(); + } + } + + Message::Disconnect(ssid) => { + self.close_popup_and_apply_updates(); + if let Some(nm) = self.nm_state.as_mut() { + _ = nm + .sender + .unbounded_send(network_manager::Request::Disconnect(ssid)); + } + } + + Message::ForgetRequest(ssid) => { + self.dialog = Some(WiFiDialog::Forget(ssid)); + self.view_more_popup = None; + } + + Message::Forget(ssid) => { + self.dialog = None; + self.close_popup_and_apply_updates(); + if let Some(nm) = self.nm_state.as_mut() { + _ = nm + .sender + .unbounded_send(network_manager::Request::Forget(ssid)); + } + } + + Message::Settings(ssid) => { + self.close_popup_and_apply_updates(); + + if let Some(uuid) = self.ssid_to_uuid.get(ssid.as_ref()).cloned() { + tokio::task::spawn( + async move { super::nm_edit_connection(uuid.as_ref()).await }, + ); + } + } + + Message::WiFiEnable(enable) => { + if let Some(nm) = self.nm_state.as_mut() { + _ = nm + .sender + .unbounded_send(network_manager::Request::SetWiFi(enable)); + _ = nm.sender.unbounded_send(network_manager::Request::Reload); + } + } + + Message::CancelDialog => { + self.dialog = None; + } + + Message::Error(why) => { + tracing::error!(why, "error in wifi settings page"); + } + + Message::NetworkManagerConnect((conn, output)) => { + self.connect(conn.clone(), output); + + return connection_settings(conn); + } + } + + Command::none() + } + + fn connect( + &mut self, + conn: zbus::Connection, + sender: tokio::sync::mpsc::Sender, + ) { + if self.nm_task.is_none() { + self.nm_task = Some(crate::utils::forward_event_loop( + sender, + |event| crate::pages::Message::WiFi(Message::NetworkManager(event)), + move |tx| async move { + futures::join!( + network_manager::watch(conn.clone(), tx.clone()), + network_manager::active_conns::watch(conn.clone(), tx.clone()), + network_manager::wireless_enabled::watch(conn.clone(), tx.clone()), + network_manager::watch_connections_changed(conn, tx) + ); + }, + )); + } + } + + /// Closes the view more popup and applies any withheld updates. + fn close_popup_and_apply_updates(&mut self) { + self.view_more_popup = None; + if let Some(ref mut nm_state) = self.nm_state { + if let Some(state) = self.withheld_state.take() { + nm_state.state = state; + } + + if let Some(devices) = self.withheld_devices.take() { + nm_state.devices = devices; + } + } + } + + /// Withholds updates if the view more popup is displayed. + fn update_devices(&mut self, devices: Vec) { + if let Some(ref mut nm_state) = self.nm_state { + if self.view_more_popup.is_some() { + self.withheld_devices = Some(devices); + } else { + nm_state.devices = devices; + } + } + } + + /// Withholds updates if the view more popup is displayed. + fn update_state(&mut self, state: NetworkManagerState) { + if let Some(ref mut nm_state) = self.nm_state { + if self.view_more_popup.is_some() { + self.withheld_state = Some(state); + } else { + nm_state.state = state; + } + } + } +} + +fn devices_view() -> Section { + crate::slab!(descriptions { + airplane_mode_txt = fl!("airplane-on"); + connect_txt = fl!("connect"); + connected_txt = fl!("connected"); + connecting_txt = fl!("connecting"); + disconnect_txt = fl!("disconnect"); + forget_txt = fl!("wifi", "forget"); + known_networks_txt = fl!("known-networks"); + no_networks_txt = fl!("no-networks"); + settings_txt = fl!("settings"); + visible_networks_txt = fl!("visible-networks"); + wifi_txt = fl!("wifi"); + }); + + Section::default() + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let Some(NmState { ref state, .. }) = page.nm_state else { + return cosmic::widget::column().into(); + }; + + let theme = cosmic::theme::active(); + let spacing = &theme.cosmic().spacing; + + let wifi_enable = + widget::settings::item::builder(§ion.descriptions[wifi_txt]).control( + widget::toggler(None, state.wifi_enabled, Message::WiFiEnable), + ); + + let mut view = widget::column::with_capacity(4) + .push(widget::list_column().add(wifi_enable)) + .push_maybe(state.airplane_mode.then(|| { + widget::row::with_capacity(2) + .push(icon::from_name("airplane-mode-symbolic")) + .push(widget::text::body(§ion.descriptions[airplane_mode_txt])) + .spacing(8) + .align_items(alignment::Alignment::Center) + .apply(widget::container) + .width(Length::Fill) + .align_x(alignment::Horizontal::Center) + })); + + if !state.airplane_mode + && state.known_access_points.is_empty() + && state.wireless_access_points.is_empty() + { + let no_networks_found = + widget::container(widget::text::body(§ion.descriptions[no_networks_txt])) + .align_x(alignment::Horizontal::Center) + .width(Length::Fill); + + view = view.push(no_networks_found); + } else { + let mut has_known = false; + let mut has_visible = false; + + // Create separate sections for known and visible networks. + let (known_networks, visible_networks) = state.wireless_access_points.iter().fold( + ( + widget::settings::view_section(§ion.descriptions[known_networks_txt]), + widget::settings::view_section(§ion.descriptions[visible_networks_txt]), + ), + |(mut known_networks, mut visible_networks), network| { + let is_connected = is_connected(state, network); + + let is_known = state + .known_access_points + .iter() + .map(|known| known.ssid.as_ref()) + .chain(state.active_conns.iter().filter_map(|active| { + if let ActiveConnectionInfo::WiFi { name, .. } = active { + Some(name.as_str()) + } else { + None + } + })) + .any(|known| known == network.ssid.as_ref()); + + // TODO: detect if access point is secured or not. + let is_encrypted = true; + + let (connect_txt, connect_msg) = if is_connected { + (§ion.descriptions[connected_txt], None) + } else if page.connecting.contains(&network.ssid) { + (§ion.descriptions[connecting_txt], None) + } else { + ( + §ion.descriptions[connect_txt], + Some(if is_known || !is_encrypted { + Message::Connect(network.ssid.clone()) + } else { + Message::PasswordRequest(network.ssid.clone()) + }), + ) + }; + + let identifier = widget::row::with_capacity(3) + .push(widget::icon::from_name("network-wireless-good-symbolic")) + .push_maybe( + is_encrypted + .then(|| widget::icon::from_name("connection-secure-symbolic")), + ) + .push(widget::text::body(network.ssid.as_ref()).wrap(Wrap::Glyph)) + .spacing(spacing.space_xxs); + + let connect: Element<'_, Message> = if let Some(msg) = connect_msg { + widget::button::text(connect_txt).on_press(msg).into() + } else { + widget::text::body(connect_txt) + .vertical_alignment(alignment::Vertical::Center) + .into() + }; + + let view_more_button = + widget::button::icon(widget::icon::from_name("view-more-symbolic")); + + let view_more: Option> = if page + .view_more_popup + .as_deref() + .map_or(false, |id| id == network.ssid.as_ref()) + { + widget::popover(view_more_button.on_press(Message::ViewMore(None))) + .position(widget::popover::Position::Bottom) + .on_close(Message::ViewMore(None)) + .popup({ + widget::column() + .push_maybe(is_connected.then(|| { + popup_button( + Message::Disconnect(network.ssid.clone()), + §ion.descriptions[disconnect_txt], + ) + })) + .push(popup_button( + Message::Settings(network.ssid.clone()), + §ion.descriptions[settings_txt], + )) + .push_maybe(is_known.then(|| { + popup_button( + Message::ForgetRequest(network.ssid.clone()), + §ion.descriptions[forget_txt], + ) + })) + .width(Length::Fixed(170.0)) + .apply(widget::container) + .style(cosmic::style::Container::Dialog) + }) + .apply(|e| Some(Element::from(e))) + } else if is_known { + view_more_button + .on_press(Message::ViewMore(Some(network.ssid.clone()))) + .apply(|e| Some(Element::from(e))) + } else { + None + }; + + let controls = widget::row::with_capacity(2) + .push(connect) + .push_maybe(view_more) + .align_items(alignment::Alignment::Center) + .spacing(spacing.space_xxs); + + let widget = widget::settings::item_row(vec![ + identifier.into(), + widget::horizontal_space(Length::Fill).into(), + controls.into(), + ]); + + if is_known { + has_known = true; + known_networks = known_networks.add(widget); + } else { + has_visible = true; + visible_networks = visible_networks.add(widget); + } + + (known_networks, visible_networks) + }, + ); + + if has_known { + view = view.push(known_networks); + } + + if has_visible { + view = view.push(visible_networks); + } + }; + + view.spacing(spacing.space_l) + .apply(Element::from) + .map(crate::pages::Message::WiFi) + }) +} + +fn is_connected(state: &NetworkManagerState, network: &AccessPoint) -> bool { + state.active_conns.iter().any(|active| { + if let ActiveConnectionInfo::WiFi { ref name, .. } = active { + *name == network.ssid.as_ref() + } else { + false + } + }) +} + +fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> { + widget::text::body(text) + .vertical_alignment(alignment::Vertical::Center) + .apply(widget::button) + .padding([4, 16]) + .width(Length::Fill) + .style(cosmic::theme::Button::MenuItem) + .on_press(message) + .into() +} + +fn connection_settings(conn: zbus::Connection) -> Command { + let settings = async move { + let settings = network_manager::dbus::settings::NetworkManagerSettings::new(&conn).await?; + + _ = settings.load_connections(&[]).await; + + let settings = settings + // Get a list of known connections. + .list_connections() + .await? + // Prepare for wrapping in a concurrent stream. + .into_iter() + .map(|conn| async move { conn }) + // Create a concurrent stream for each connection. + .apply(futures::stream::FuturesOrdered::from_iter) + // Concurrently fetch settings for each connection. + .filter_map(|conn| async move { + conn.get_settings() + .await + .map(network_manager::Settings::new) + .ok() + }) + // Reduce the settings list into a SSID->UUID map. + .fold(BTreeMap::new(), |mut set, settings| async move { + if let Some(ref wifi) = settings.wifi { + if let Some(ssid) = wifi + .ssid + .clone() + .and_then(|ssid| String::from_utf8(ssid).ok()) + { + if let Some(ref connection) = settings.connection { + if let Some(uuid) = connection.uuid.clone() { + set.insert(ssid.into(), uuid.into()); + return set; + } + } + } + } + + set + }) + .await; + + Ok::<_, zbus::Error>(settings) + }; + + cosmic::command::future(async move { + settings + .await + .context("failed to get connection settings") + .map_or_else( + |why| Message::Error(why.to_string()), + Message::ConnectionSettings, + ) + .apply(crate::pages::Message::WiFi) + }) +} + +pub fn update_state(conn: zbus::Connection) -> Command { + cosmic::command::future(async move { + match NetworkManagerState::new(&conn).await { + Ok(state) => Message::UpdateState(state), + Err(why) => Message::Error(why.to_string()), + } + }) +} + +pub fn update_devices(conn: zbus::Connection) -> Command { + cosmic::command::future(async move { + let filter = + |device_type| matches!(device_type, network_manager::devices::DeviceType::Wifi); + match network_manager::devices::list(&conn, filter).await { + Ok(devices) => Message::UpdateDevices(devices), + Err(why) => Message::Error(why.to_string()), + } + }) +} diff --git a/cosmic-settings/src/pages/networking/wired.rs b/cosmic-settings/src/pages/networking/wired.rs index 4ca33ab..3677f53 100644 --- a/cosmic-settings/src/pages/networking/wired.rs +++ b/cosmic-settings/src/pages/networking/wired.rs @@ -1,10 +1,690 @@ -// Copyright 2023 System76 +// Copyright 2024 System76 // SPDX-License-Identifier: GPL-3.0-only -use cosmic_settings_page as page; +use std::{collections::BTreeSet, sync::Arc}; -pub fn info() -> page::Info { - page::Info::new("wired", "network-workgroup-symbolic") - .title(fl!("wired")) - .description(fl!("wired", "desc")) +use anyhow::Context; +use cosmic::{ + iced::{alignment, Length}, + iced_core::text::Wrap, + prelude::CollectionWidget, + widget::{self, icon}, + Apply, Command, Element, +}; +use cosmic_settings_page::{self as page, section, Section}; +use cosmic_settings_subscriptions::network_manager::{ + self, current_networks::ActiveConnectionInfo, devices::DeviceState, NetworkManagerState, +}; +use slab::Slab; + +pub type ConnectionId = Arc; + +#[derive(Clone, Debug)] +pub enum Message { + /// Activate a connection + Activate(ConnectionId), + /// Add a network connection with nm-connection-editor + AddNetwork, + /// Cancels an active dialog. + CancelDialog, + /// Deactivate a connection. + Deactivate(ConnectionId), + /// An error occurred. + Error(String), + /// An update from the network manager daemon + NetworkManager(network_manager::Event), + /// Successfully connected to the system dbus. + NetworkManagerConnect( + ( + zbus::Connection, + tokio::sync::mpsc::Sender, + ), + ), + /// Refresh devices and their connection profiles + Refresh, + /// Create a dialog to ask for confirmation of removal. + RemoveProfileRequest(ConnectionId), + /// Remove a connection profile + RemoveProfile(ConnectionId), + /// Selects a device to display connections from + SelectDevice(Arc), + /// Opens settings page for the access point. + Settings(ConnectionId), + /// Update NetworkManagerState + UpdateState(NetworkManagerState), + /// Update the devices lists + UpdateDevices(Vec), + /// Display more options for an access point + ViewMore(Option), +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Wired(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Wired(message) + } +} + +pub type InterfaceId = String; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum WiredDialog { + RemoveProfile(ConnectionId), +} + +#[derive(Debug, Default)] +pub struct Page { + nm_task: Option>, + nm_state: Option, + dialog: Option, + /// When defined, displays connections for the specific device. + active_device: Option>, + /// Tracks which connections are in the act of connecting. + connecting: BTreeSet, + /// Displays a popup when set. + view_more_popup: Option, + /// Withhold device update if the view more popup is shown. + withheld_devices: Option>>, + /// Withhold active connections update if the view more popup is shown. + withheld_active_conns: Option>, +} + +#[derive(Debug)] +pub struct NmState { + conn: zbus::Connection, + sender: futures::channel::mpsc::UnboundedSender, + active_conns: Vec, + devices: Vec>, +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn info(&self) -> cosmic_settings_page::Info { + page::Info::new("wired", "preferences-wired-symbolic") + .title(fl!("wired")) + .description(fl!("connections-and-profiles", variant = "wired")) + } + + fn content( + &self, + sections: &mut slotmap::SlotMap>, + ) -> Option { + Some(vec![sections.insert(devices_view())]) + } + + fn dialog(&self) -> Option> { + self.dialog.as_ref().map(|dialog| match dialog { + WiredDialog::RemoveProfile(uuid) => { + let primary_action = widget::button::destructive(fl!("remove")) + .on_press(Message::RemoveProfile(uuid.clone())); + + let secondary_action = + widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog); + + widget::dialog(fl!("remove-connection-dialog")) + .icon(icon::from_name("dialog-information").size(64)) + .body(fl!("remove-connection-dialog", "wired-description")) + .primary_action(primary_action) + .secondary_action(secondary_action) + .apply(Element::from) + .map(crate::pages::Message::Wired) + } + }) + } + + fn header_view(&self) -> Option> { + Some( + widget::button::standard(fl!("add-network")) + .trailing_icon(icon::from_name("window-pop-out-symbolic")) + .on_press(Message::AddNetwork) + .apply(widget::container) + .width(Length::Fill) + .align_x(alignment::Horizontal::Right) + .apply(Element::from) + .map(crate::pages::Message::Wired), + ) + } + + fn on_enter( + &mut self, + _page: cosmic_settings_page::Entity, + sender: tokio::sync::mpsc::Sender, + ) -> cosmic::Command { + if self.nm_task.is_none() { + return cosmic::command::future(async move { + zbus::Connection::system() + .await + .context("failed to create system dbus connection") + .map_or_else( + |why| Message::Error(why.to_string()), + |conn| Message::NetworkManagerConnect((conn, sender.clone())), + ) + .apply(crate::pages::Message::Wired) + }); + } + + Command::none() + } + + fn on_leave(&mut self) -> Command { + self.active_device = None; + self.view_more_popup = None; + self.nm_state = None; + self.withheld_active_conns = None; + self.withheld_devices = None; + self.connecting.clear(); + + if let Some(cancel) = self.nm_task.take() { + _ = cancel.send(()); + } + + Command::none() + } + + fn title(&self) -> Option<&str> { + self.active_device + .as_ref() + .map(|device| device.interface.as_str()) + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> Command { + match message { + Message::NetworkManager(network_manager::Event::RequestResponse { + req, + state, + success, + }) => { + if !success { + tracing::error!(request = ?req, "network-manager request failed"); + } + + if let Some(NmState { ref conn, .. }) = self.nm_state { + let conn = conn.clone(); + self.update_active_conns(state); + return update_devices(conn); + } + } + + Message::UpdateDevices(devices) => { + self.update_devices(devices); + } + + Message::UpdateState(state) => { + self.update_active_conns(state); + } + + Message::SelectDevice(device) => { + self.active_device = Some(device); + } + + Message::NetworkManager( + network_manager::Event::ActiveConns | network_manager::Event::Devices, + ) => { + if let Some(NmState { ref conn, .. }) = self.nm_state { + return cosmic::command::batch(vec![ + update_state(conn.clone()), + update_devices(conn.clone()), + ]); + } + } + + Message::NetworkManager(network_manager::Event::Init { + conn, + sender, + state, + }) => { + self.nm_state = Some(NmState { + conn: conn.clone(), + sender, + devices: Vec::new(), + active_conns: state + .active_conns + .into_iter() + .filter(|info| matches!(info, ActiveConnectionInfo::Wired { .. })) + .collect(), + }); + + return update_devices(conn); + } + + Message::NetworkManager(_event) => (), + + Message::AddNetwork => { + return cosmic::command::future(async move { + _ = super::nm_add_wired().await; + // TODO: Update when iced is rebased to use then method. + Message::Refresh + }); + } + + Message::Activate(uuid) => { + self.close_popup_and_apply_updates(); + + if let Some(NmState { + ref devices, + ref sender, + .. + }) = self.nm_state + { + for device in devices { + let device_conn = device + .available_connections + .iter() + .find(|conn| conn.uuid.as_ref() == uuid.as_ref()); + + if let Some(device_conn) = device_conn { + let device_path = device.path.clone(); + let conn_path = device_conn.path.clone(); + + _ = sender.unbounded_send(network_manager::Request::Activate( + device_path, + conn_path, + )); + + break; + } + } + } + } + + Message::Deactivate(uuid) => { + self.close_popup_and_apply_updates(); + if let Some(NmState { ref sender, .. }) = self.nm_state { + _ = sender.unbounded_send(network_manager::Request::Deactivate(uuid)); + } + } + + Message::RemoveProfileRequest(uuid) => { + self.view_more_popup = None; + self.dialog = Some(WiredDialog::RemoveProfile(uuid)); + } + + Message::RemoveProfile(uuid) => { + self.dialog = None; + self.close_popup_and_apply_updates(); + if let Some(NmState { ref sender, .. }) = self.nm_state { + _ = sender.unbounded_send(network_manager::Request::Remove(uuid)); + } + } + + Message::ViewMore(uuid) => { + self.view_more_popup = uuid; + if self.view_more_popup.is_none() { + self.close_popup_and_apply_updates(); + } + } + + Message::Settings(uuid) => { + self.close_popup_and_apply_updates(); + + return cosmic::command::future(async move { + _ = super::nm_edit_connection(uuid.as_ref()).await; + // TODO: Update when iced is rebased to use then method. + Message::Refresh + }); + } + + Message::Refresh => { + if let Some(NmState { ref conn, .. }) = self.nm_state { + return cosmic::command::batch(vec![ + update_state(conn.clone()), + update_devices(conn.clone()), + ]); + } + } + + Message::CancelDialog => { + self.dialog = None; + } + + Message::Error(why) => { + tracing::error!(why, "error in wired settings page"); + } + + Message::NetworkManagerConnect((conn, output)) => { + self.connect(conn.clone(), output); + } + } + + Command::none() + } + + fn connect( + &mut self, + conn: zbus::Connection, + sender: tokio::sync::mpsc::Sender, + ) { + if self.nm_task.is_none() { + self.nm_task = Some(crate::utils::forward_event_loop( + sender, + |event| crate::pages::Message::Wired(Message::NetworkManager(event)), + move |tx| async move { + futures::join!( + network_manager::watch(conn.clone(), tx.clone()), + network_manager::active_conns::watch(conn.clone(), tx.clone()), + network_manager::devices::watch(conn, true, tx) + ); + }, + )); + } + } + + /// Closes the view more popup and applies any withheld updates. + fn close_popup_and_apply_updates(&mut self) { + self.view_more_popup = None; + if let Some(ref mut nm_state) = self.nm_state { + if let Some(active_conns) = self.withheld_active_conns.take() { + nm_state.active_conns = active_conns; + } + + if let Some(devices) = self.withheld_devices.take() { + nm_state.devices = devices; + } + } + + self.update_active_device(); + } + + fn update_active_device(&mut self) { + if let Some((nm_state, active)) = self.nm_state.as_ref().zip(self.active_device.as_ref()) { + self.active_device = nm_state + .devices + .iter() + .find(|device| device.path == active.path) + .map(Arc::clone); + } + } + + /// Withholds updates if the view more popup is displayed. + fn update_devices(&mut self, devices: Vec) { + if let Some(ref mut nm_state) = self.nm_state { + let devices = devices.into_iter().map(Arc::new).collect(); + if self.view_more_popup.is_some() { + self.withheld_devices = Some(devices); + } else { + nm_state.devices = devices; + } + } + + self.update_active_device(); + } + + /// Withholds updates if the view more popup is displayed. + fn update_active_conns(&mut self, state: NetworkManagerState) { + if let Some(ref mut nm_state) = self.nm_state { + let conns = state + .active_conns + .into_iter() + .filter(|info| matches!(info, ActiveConnectionInfo::Wired { .. })) + .collect(); + + if self.view_more_popup.is_some() { + self.withheld_active_conns = Some(conns); + } else { + nm_state.active_conns = conns; + } + } + } + + fn device_view<'a>( + &'a self, + spacing: &cosmic::cosmic_theme::Spacing, + nm_state: &'a NmState, + connect_txt: &'a str, + connected_txt: &'a str, + disconnect_txt: &'a str, + remove_txt: &'a str, + settings_txt: &'a str, + wired_conns_txt: &'a str, + device: &'a network_manager::devices::DeviceInfo, + ) -> Element<'a, Message> { + let has_multiple_connection_profiles = device.available_connections.len() > 1; + let header_txt = format!("{}", wired_conns_txt); + + device + .available_connections + .iter() + .fold( + widget::settings::view_section(header_txt), + |networks, connection| { + let is_connected = nm_state.active_conns.iter().any(|conn| match conn { + ActiveConnectionInfo::Wired { name, .. } => { + name.as_str() == connection.id.as_str() + } + + _ => false, + }); + + let (connect_txt, connect_msg) = if is_connected { + (connected_txt, None) + } else { + ( + connect_txt, + Some(Message::Activate(connection.uuid.clone())), + ) + }; + + let identifier = widget::text::body(&connection.id).wrap(Wrap::Glyph); + + let connect: Element<'_, Message> = if let Some(msg) = connect_msg { + widget::button::text(connect_txt).on_press(msg).into() + } else { + widget::text::body(connect_txt) + .vertical_alignment(alignment::Vertical::Center) + .into() + }; + + let view_more_button = + widget::button::icon(widget::icon::from_name("view-more-symbolic")); + + let view_more: Option> = if self + .view_more_popup + .as_deref() + .map_or(false, |id| id == connection.uuid.as_ref()) + { + widget::popover(view_more_button.on_press(Message::ViewMore(None))) + .position(widget::popover::Position::Bottom) + .on_close(Message::ViewMore(None)) + .popup({ + widget::column() + .push_maybe(is_connected.then(|| { + popup_button( + Message::Deactivate(connection.uuid.clone()), + &disconnect_txt, + ) + })) + .push(popup_button( + Message::Settings(connection.uuid.clone()), + &settings_txt, + )) + .push_maybe(has_multiple_connection_profiles.then(|| { + popup_button( + Message::RemoveProfileRequest(connection.uuid.clone()), + &remove_txt, + ) + })) + .width(Length::Fixed(200.0)) + .apply(widget::container) + .style(cosmic::style::Container::Dialog) + }) + .apply(|e| Some(Element::from(e))) + } else { + view_more_button + .on_press(Message::ViewMore(Some(connection.uuid.clone()))) + .apply(|e| Some(Element::from(e))) + }; + + let controls = widget::row::with_capacity(2) + .push(connect) + .push_maybe(view_more) + .align_items(alignment::Alignment::Center) + .spacing(spacing.space_xxs); + + let widget = widget::settings::item_row(vec![ + identifier.into(), + widget::horizontal_space(Length::Fill).into(), + controls.into(), + ]); + + networks.add(widget) + }, + ) + .into() + } + + fn device_list_view<'a>( + &'a self, + _spacing: &cosmic::cosmic_theme::Spacing, + nm_state: &'a NmState, + devices_txt: &'a str, + ) -> Element<'a, Message> { + nm_state + .devices + .iter() + .fold( + widget::settings::view_section(devices_txt), + |section, device| { + let is_unplugged = matches!(device.state, DeviceState::Unavailable); + + let device_list = + cosmic::widget::settings::item::builder(device.interface.as_str()) + .description(match device.state { + DeviceState::Activated => fl!("network-device-state", "activated"), + DeviceState::Config => fl!("network-device-state", "config"), + DeviceState::Deactivating => { + fl!("network-device-state", "deactivating") + } + DeviceState::Disconnected => { + fl!("network-device-state", "disconnected") + } + DeviceState::Failed => fl!("network-device-state", "failed"), + DeviceState::IpCheck => fl!("network-device-state", "ip-check"), + DeviceState::IpConfig => fl!("network-device-state", "ip-config"), + DeviceState::NeedAuth => fl!("network-device-state", "need-auth"), + DeviceState::Prepare => fl!("network-device-state", "prepare"), + DeviceState::Secondaries => { + fl!("network-device-state", "secondaries") + } + DeviceState::Unavailable => { + fl!("network-device-state", "unplugged") + } + DeviceState::Unknown => fl!("network-device-state", "unknown"), + DeviceState::Unmanaged => fl!("network-device-state", "unmanaged"), + }) + .icon(icon::from_name("network-wired-symbolic").size(32)) + .control(icon::from_name("go-next-symbolic").size(20)) + .spacing(16) + .apply(widget::container) + .padding([16, 14]) + .style(cosmic::theme::Container::List) + .apply(widget::button) + .padding(0) + .style(cosmic::theme::Button::Transparent) + .on_press_maybe(if is_unplugged { + None + } else { + Some(Message::SelectDevice(device.clone())) + }); + + section.add(device_list) + }, + ) + .into() + } +} + +fn devices_view() -> Section { + crate::slab!(descriptions { + wired_conns_txt = fl!("wired", "connections"); + wired_devices_txt = fl!("wired", "devices"); + remove_txt = fl!("wired", "remove"); + connect_txt = fl!("connect"); + connected_txt = fl!("connected"); + settings_txt = fl!("settings"); + disconnect_txt = fl!("disconnect"); + }); + + Section::default() + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let Some(ref nm_state) = page.nm_state else { + return cosmic::widget::column().into(); + }; + + let theme = cosmic::theme::active(); + let spacing = &theme.cosmic().spacing; + + let mut view = widget::column::with_capacity(4); + + // Displays device connections if a device is selected, or only device exists. + let active_device = page + .active_device + .as_ref() + .or_else(|| (nm_state.devices.len() == 1).then(|| nm_state.devices.get(0))?) + .filter(|device| !matches!(device.state, DeviceState::Unavailable)); + + view = match active_device { + Some(device) => view.push(page.device_view( + spacing, + nm_state, + §ion.descriptions[connect_txt], + §ion.descriptions[connected_txt], + §ion.descriptions[disconnect_txt], + §ion.descriptions[remove_txt], + §ion.descriptions[settings_txt], + §ion.descriptions[wired_conns_txt], + device, + )), + + None => view.push(page.device_list_view( + spacing, + nm_state, + §ion.descriptions[wired_devices_txt], + )), + }; + + view.spacing(spacing.space_l) + .apply(Element::from) + .map(crate::pages::Message::Wired) + }) +} + +fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> { + widget::text::body(text) + .vertical_alignment(alignment::Vertical::Center) + .apply(widget::button) + .padding([4, 16]) + .width(Length::Fill) + .style(cosmic::theme::Button::MenuItem) + .on_press(message) + .into() +} + +fn update_state(conn: zbus::Connection) -> Command { + cosmic::command::future(async move { + match NetworkManagerState::new(&conn).await { + Ok(state) => Message::UpdateState(state), + Err(why) => Message::Error(why.to_string()), + } + }) +} + +fn update_devices(conn: zbus::Connection) -> Command { + cosmic::command::future(async move { + let filter = + |device_type| matches!(device_type, network_manager::devices::DeviceType::Ethernet); + + match network_manager::devices::list(&conn, filter).await { + Ok(devices) => Message::UpdateDevices(devices), + Err(why) => Message::Error(why.to_string()), + } + }) } diff --git a/cosmic-settings/src/utils.rs b/cosmic-settings/src/utils.rs index 22b240f..efe2984 100644 --- a/cosmic-settings/src/utils.rs +++ b/cosmic-settings/src/utils.rs @@ -46,3 +46,15 @@ pub fn forward_event_loop + Send + 'st cancel_tx } + +/// Creates a slab with predefined items +#[macro_export] +macro_rules! slab { + ( $descriptions:ident { $( $txt_id:ident = $txt_expr:expr; )+ } ) => { + let mut $descriptions = Slab::new(); + + $( + let $txt_id = $descriptions.insert($txt_expr); + )+ + } +} diff --git a/debian/control b/debian/control index be89372..72815ea 100644 --- a/debian/control +++ b/debian/control @@ -31,6 +31,9 @@ Depends: cosmic-randr, gettext, iso-codes, + network-manager-gnome, + network-manager-openvpn, + network-manager-openvpn-gnome, xkb-data, Recommends: adw-gtk3 Description: Settings application for the COSMIC desktop environment diff --git a/debian/install b/debian/install index b00cfe5..98aaf07 100644 --- a/debian/install +++ b/debian/install @@ -19,8 +19,11 @@ /usr/share/applications/com.system76.CosmicSettings.Time.desktop /usr/share/applications/com.system76.CosmicSettings.Touchpad.desktop /usr/share/applications/com.system76.CosmicSettings.Users.desktop +/usr/share/applications/com.system76.CosmicSettings.Vpn.desktop /usr/share/applications/com.system76.CosmicSettings.Wallpaper.desktop /usr/share/applications/com.system76.CosmicSettings.WindowManagement.desktop +/usr/share/applications/com.system76.CosmicSettings.Wired.desktop +/usr/share/applications/com.system76.CosmicSettings.Wireless.desktop /usr/share/applications/com.system76.CosmicSettings.Workspaces.desktop /usr/share/metainfo/com.system76.CosmicSettings.metainfo.xml /usr/share/polkit-1/rules.d/cosmic-settings.rules diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index b64d034..7b07204 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -4,10 +4,73 @@ unknown = Unknown number = { $number } -## Networking: Wired +## Network & Wireless + +connections-and-profiles = { $variant -> + [wired] Wired + [wifi] Wi-Fi + [vpn] VPN + *[other] Unknown +} connections and connection profiles. + +add-network = Add network +add-vpn = Add VPN +airplane-on = Airplane mode is on. +cable-unplugged = Cable unplugged +connect = Connect +connected = Connected +connecting = Connecting… +disconnect = Disconnect +forget = Forget +known-networks = Known Networks +network-and-wireless = Network & Wireless +no-networks = No networks have been found. +no-vpn = No VPN connections available. +password = Password +remove = Remove +settings = Settings +username = Username +visible-networks = Visible Networks + +auth-dialog = Authentication Required + .vpn-description = Enter the username and password required by the VPN service. + .wifi-description = Enter the password or encryption key. You can also connect by pressing the “WPS” button on the router. + +forget-dialog = Forget this Wi-Fi network? + .description = You'll need to enter a password again to use this Wi-Fi network in the future. + +network-device-state = + .activated = Connected to network + .config = Connecting to network + .deactivating = Disconnecting from network + .disconnected = Disconnected + .failed = Failed to connect + .ip-check = Checking connection + .ip-config = Requesting IP and routing information + .need-auth = Needs authentication + .prepare = Preparing to connect to network + .secondaries = Waiting for secondary connection + .unavailable = Unavailable + .unknown = Unknown state + .unmanaged = Unmanaged + .unplugged = Cable unplugged + +remove-connection-dialog = Remove Connection Profile? + .vpn-description = You'll need to enter a password again to use this network in the future. + .wired-description = You'll need to recreate this profile to use it in the future. + +vpn = VPN + .connections = VPN Connections + .remove = Remove connection profile + .select-file = Select a VPN configuration file wired = Wired - .desc = Wired connections and connection profiles + .connections = Wired Connections + .devices = Wired Devices + .remove = Remove connection profile + +wifi = Wi-Fi + .forget = Forget this network ## Networking: Online Accounts diff --git a/justfile b/justfile index 31abaf9..6b6ef5a 100644 --- a/justfile +++ b/justfile @@ -44,8 +44,11 @@ entry-system := appid + '.System.desktop' entry-time := appid + '.Time.desktop' entry-touchpad := appid + '.Touchpad.desktop' entry-users := appid + '.Users.desktop' +entry-vpn := appid + '.Vpn.desktop' entry-wallpaper := appid + '.Wallpaper.desktop' entry-window-management := appid + '.WindowManagement.desktop' +entry-wired := appid + '.Wired.desktop' +entry-wireless := appid + '.Wireless.desktop' entry-workspaces := appid + '.Workspaces.desktop' # Build recipes @@ -74,8 +77,11 @@ install-desktop-entries: install -Dm0644 'resources/{{entry-time}}' '{{appdir}}/{{entry-time}}' install -Dm0644 'resources/{{entry-touchpad}}' '{{appdir}}/{{entry-touchpad}}' install -Dm0644 'resources/{{entry-users}}' '{{appdir}}/{{entry-users}}' + install -Dm0644 'resources/{{entry-vpn}}' '{{appdir}}/{{entry-vpn}}' install -Dm0644 'resources/{{entry-wallpaper}}' '{{appdir}}/{{entry-wallpaper}}' install -Dm0644 'resources/{{entry-window-management}}' '{{appdir}}/{{entry-window-management}}' + install -Dm0644 'resources/{{entry-wired}}' '{{appdir}}/{{entry-wired}}' + install -Dm0644 'resources/{{entry-wireless}}' '{{appdir}}/{{entry-wireless}}' install -Dm0644 'resources/{{entry-workspaces}}' '{{appdir}}/{{entry-workspaces}}' # Install everything @@ -116,8 +122,11 @@ uninstall: '{{appdir}}/{{entry-time}}' \ '{{appdir}}/{{entry-touchpad}}' \ '{{appdir}}/{{entry-users}}' \ + '{{appdir}}/{{entry-vpn}}' \ '{{appdir}}/{{entry-wallpaper}}' \ '{{appdir}}/{{entry-window-management}}' \ + '{{appdir}}/{{entry-wired}}' \ + '{{appdir}}/{{entry-wireless}}' \ '{{appdir}}/{{entry-workspaces}}' find 'resources'/'default_schema' -type f -exec echo {} \; | rev | cut -d'/' -f-3 | rev | xargs -d '\n' -I {} rm -rf {{default-schema-target}}/{} find 'resources'/'icons' -type f -exec echo {} \; | rev | cut -d'/' -f-3 | rev | xargs -d '\n' -I {} rm {{iconsdir}}/{} diff --git a/page/src/lib.rs b/page/src/lib.rs index bec03f7..ca4dbc7 100644 --- a/page/src/lib.rs +++ b/page/src/lib.rs @@ -77,6 +77,11 @@ pub trait Page: Downcast { fn on_leave(&mut self) -> Command { Command::none() } + + /// The title to display in the page header. + fn title(&self) -> Option<&str> { + None + } } impl_downcast!(Page); diff --git a/resources/com.system76.CosmicSettings.Vpn.desktop b/resources/com.system76.CosmicSettings.Vpn.desktop new file mode 100644 index 0000000..6f6cca1 --- /dev/null +++ b/resources/com.system76.CosmicSettings.Vpn.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=VPN +Comment=VPN connections and connection profiles. +Type=Settings +Exec=cosmic-settings vpn +Terminal=false +Categories=COSMIC +Keywords=COSMIC +NoDisplay=true +OnlyShowIn=COSMIC +Icon=preferences-vpn +StartupNotify=true diff --git a/resources/com.system76.CosmicSettings.Wired.desktop b/resources/com.system76.CosmicSettings.Wired.desktop new file mode 100644 index 0000000..f26af03 --- /dev/null +++ b/resources/com.system76.CosmicSettings.Wired.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Wired +Comment=Wired connections and connection profiles. +Type=Settings +Exec=cosmic-settings wired +Terminal=false +Categories=COSMIC +Keywords=COSMIC +NoDisplay=true +OnlyShowIn=COSMIC +Icon=preferences-wired +StartupNotify=true diff --git a/resources/com.system76.CosmicSettings.Wireless.desktop b/resources/com.system76.CosmicSettings.Wireless.desktop new file mode 100644 index 0000000..7dd28b9 --- /dev/null +++ b/resources/com.system76.CosmicSettings.Wireless.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Wi-Fi +Comment=Wi-Fi connections and connection profiles. +Type=Settings +Exec=cosmic-settings wireless +Terminal=false +Categories=COSMIC +Keywords=COSMIC +NoDisplay=true +OnlyShowIn=COSMIC +Icon=preferences-wireless +StartupNotify=true