├── i18n ├── nl │ └── gui_scale_applet.ftl ├── sv │ └── gui_scale_applet.ftl └── en │ └── gui_scale_applet.ftl ├── i18n.toml ├── screenshots ├── gui-scale-panel.png └── gui-scale-applet-open.png ├── data ├── icons │ └── scalable │ │ └── apps │ │ └── tailscale-icon.png ├── com.github.bhh32.GUIScaleApplet.desktop ├── com.bhh32.GUIScaleApplet.desktop └── com.github.bhh32.GUIScaleApplet.metainfo.xml ├── src ├── main.rs ├── config.rs ├── logic.rs └── window.rs ├── .gitignore ├── Cargo.toml ├── .github └── FUNDING.yml ├── LICENSE ├── README.md └── justfile /i18n/nl/gui_scale_applet.ftl: -------------------------------------------------------------------------------- 1 | cosmic-applet-button = COSMIC-knop 2 | -------------------------------------------------------------------------------- /i18n/sv/gui_scale_applet.ftl: -------------------------------------------------------------------------------- 1 | cosmic-applet-button = Cosmic knapp 2 | -------------------------------------------------------------------------------- /i18n/en/gui_scale_applet.ftl: -------------------------------------------------------------------------------- 1 | cosmic-applet-button = Cosmic Button 2 | -------------------------------------------------------------------------------- /i18n.toml: -------------------------------------------------------------------------------- 1 | fallback_langage = "en" 2 | 3 | [fluent] 4 | assets_dir = "i18n" -------------------------------------------------------------------------------- /screenshots/gui-scale-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-utils/gui-scale-applet/HEAD/screenshots/gui-scale-panel.png -------------------------------------------------------------------------------- /screenshots/gui-scale-applet-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-utils/gui-scale-applet/HEAD/screenshots/gui-scale-applet-open.png -------------------------------------------------------------------------------- /data/icons/scalable/apps/tailscale-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-utils/gui-scale-applet/HEAD/data/icons/scalable/apps/tailscale-icon.png -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod logic; 3 | mod window; 4 | 5 | use crate::window::Window; 6 | 7 | fn main() -> cosmic::iced::Result { 8 | cosmic::applet::run::(())?; 9 | 10 | Ok(()) 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | # VSCode 9 | .vscode/ 10 | 11 | # Flatpak files 12 | .flatpak-builder/ 13 | repo/ 14 | 15 | # Archives 16 | *.flatpak 17 | *.tar.gz 18 | -------------------------------------------------------------------------------- /data/com.github.bhh32.GUIScaleApplet.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=GUI-Scale Applet 3 | Type=Application 4 | Exec=gui-scale-applet 5 | Terminal=false 6 | Categories=COSMIC; 7 | Keywords=COSMIC;Iced; 8 | Icon=tailscale-icon 9 | StartupNotify=true 10 | NoDisplay=true 11 | X-CosmicApplet=true 12 | X-CosmicHoverPopup=Start 13 | x-OverflowPriority=10 14 | -------------------------------------------------------------------------------- /data/com.bhh32.GUIScaleApplet.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=GUI-Scale Applet 3 | Type=Application 4 | Exec=/usr/bin/gui-scale-applet 5 | Terminal=false 6 | Categories=Network;Utility 7 | Keywords=Tailscale;VPN;Network;COSMIC;Iced; 8 | Icon=tailscale-icon 9 | StartupNotify=true 10 | NoDisplay=true 11 | X-CosmicApplet=true 12 | X-CosmicHoverPopup=Start 13 | X-OverflowPriority=10 14 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gui-scale-applet" 3 | version = "3.0.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | rust-embed = "8.0.0" 8 | tokio = "1.31" 9 | serde = "1.0.210" 10 | url = "2.4.0" 11 | regex = "1.11.1" 12 | 13 | [dependencies.libcosmic] 14 | git = "https://github.com/pop-os/libcosmic.git" 15 | default-features = false 16 | features = ["applet", "wayland", "tokio", "desktop"] 17 | 18 | [features] 19 | xdg-portal = ["libcosmic/xdg-portal"] 20 | rfd = ["libcosmic/rfd"] 21 | default = ["xdg-portal"] 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [bhh32] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: bhh32 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /data/com.github.bhh32.GUIScaleApplet.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.github.bhh32.GUIScaleApplet 4 | CC0-1.0 5 | BSD-3.0-only 6 | COSMIC 7 | Bryan Hyland 8 | bryan.hyland32@gmail.com 9 | 10 | GUI-Scale Applet 11 | Linux GUI Wrapper Applet for the Tailscale CLI 12 | 13 |

Linux GUI wrapper applet for the Tailscale CLI

14 |
15 | com.github.bhh32.GUIScaleApplet.desktop 16 | https://raw.githubusercontent.com/bhh32/gui-scale-applet/data/icons/tailscale-icon.png 17 | 18 | 19 | 20 | text/plain 21 | 22 | 23 | gui-scale-applet 24 | 25 | 26 |
-------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use cosmic::cosmic_config::{Config, ConfigGet, ConfigSet}; 4 | use serde::{de::DeserializeOwned, Serialize}; 5 | 6 | pub fn update_config(config: Config, key: &str, value: T) 7 | where 8 | T: Serialize + Display + Clone, 9 | { 10 | let config_set = config.set(key, value.clone()); 11 | 12 | match config_set { 13 | Ok(_) => println!("Config variable for {key} was set to {value}"), 14 | Err(e) => eprintln!("Something went wrong setting {key} to {value}: {e}"), 15 | } 16 | 17 | let config_tx = config.transaction(); 18 | let tx_result = config_tx.commit(); 19 | 20 | match tx_result { 21 | Ok(_) => println!("Config transaction has been completed!"), 22 | Err(e) => eprintln!("Something with the config transaction when wrong: {e}"), 23 | } 24 | } 25 | 26 | pub fn load_config(key: &str, config_vers: u64) -> (Option, String) 27 | where 28 | T: DeserializeOwned, 29 | { 30 | let config = match Config::new("com.github.bhh32.GUIScaleApplet", config_vers) { 31 | Ok(config) => config, 32 | Err(e) => { 33 | eprintln!("Loading config file had an error: {e}"); 34 | Config::system("com.github.bhh32.GUIScaleApplet", 1).unwrap() 35 | } 36 | }; 37 | 38 | match config.get(key) { 39 | Ok(value) => (Some(value), "".to_owned()), 40 | Err(_e) => { 41 | update_config(config, key, ""); 42 | (None, "Created config for key".to_owned()) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, COSMIC Utilities 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GUI Scale COSMIC Desktop Applet 2 | 3 | ## About 4 | 5 | A secure, memory-safe implementation of a Tailscale GUI management applet for the System76 COSMIC desktop environment. This project demonstrates secure systems programming practices while providing essential functionality for Tailscale network (tailnet) management. 6 | 7 | ### Project Overview 8 | 9 | The GUI Scale applet provides a user-friendly interface for managing Tailscale configurations within the COSMIC desktop environment. It showcases secure systems programming practices while maintaining high usability standards. 10 | 11 | ### Key Features 12 | 13 | - Secure Tailscale network (tailnet) managment 14 | - Memory-safe implementation in Rust 15 | - Utilizes Rust's ownership system for memory-safety operations 16 | - No unsafe code blocks used 17 | - All external data properly validated 18 | - Buffer overflow protection through Rust's bounds checking 19 | - Comprehensive error handling 20 | - Integration with system security policies 21 | - Proper permission handling for system operations 22 | - Does not run while the Tailscale operator configuration is set to root. 23 | - Minimal system privilege requirements 24 | - Does not run as root 25 | 26 | ### Security Architecture 27 | 28 | The applet implements a layered security approach: 29 | 30 | 1. Privilege Management 31 | - Minimal required permissions 32 | - Proper capabilitiy isolation 33 | 2. Error Handling 34 | - Uses Rust's built-in error types (Result and Option), as well as return other types, such as String and bool, to test for and handle any errors that occurred during runtime 35 | - No information leakage 36 | 37 | ## Dependencies 38 | 39 | You must first have Tailscale installed and then run: 40 | 41 | ```bash 42 | sudo tailscale set --operator=$USER 43 | ``` 44 | 45 | This makes it where the applet doesn't need sudo (root) to do its job. 46 | 47 | ## Screenshots 48 | 49 | ![gui-scale-applet-panel](/screenshots/gui-scale-panel.png) 50 | ![gui-scale-applet-open](/screenshots/gui-scale-applet-open.png) 51 | 52 | ## Installation 53 | 54 | ### Fedora/Fedora based distros 55 | 56 | Add the Copr repo: 57 | 58 | ```bash 59 | sudo dnf copr enable bhh32/gui-scale-applet 60 | sudo dnf update --refresh 61 | sudo dnf install -y gui-scale-applet 62 | ``` 63 | 64 | ### Debian/Ubuntu (including Pop!OS) based Distros 65 | 66 | Unfortunately, I don't know anything like Copr for these distros, so you can download the deb package from the releases section of this repo. 67 | 68 | ### Other 69 | 70 | For any other distros (except atomic/immutable distros) you can run: 71 | 72 | ```bash 73 | git clone https://github.com/cosmic-utils/gui-scale-applet.git 74 | cd gui-scale-applet 75 | sudo just install 76 | ``` 77 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | name := 'gui-scale-applet' 2 | export APPID := 'com.bhh32.GUIScaleApplet' 3 | rootdir := '' 4 | prefix := '/usr' 5 | base-dir := absolute_path(clean(rootdir / prefix)) 6 | export INSTALL_DIR := base-dir / 'share' 7 | bin-src := 'target' / 'release' / name 8 | bin-dst := base-dir / 'bin' / name 9 | desktop := APPID + '.desktop' 10 | desktop-src := 'data' / desktop 11 | desktop-dst := clean(rootdir / prefix) / 'share' / 'applications' / desktop 12 | metainfo := APPID + '.metainfo.xml' 13 | metainfo-src := 'data' / metainfo 14 | metainfo-dst := clean(rootdir / prefix) / 'share' / 'metainfo' / metainfo 15 | icon := 'tailscale-icon.png' 16 | icons-src := 'data' / 'icons' / 'scalable' / 'apps' / icon 17 | icons-dst := clean(rootdir / prefix) / 'share' / 'icons' / 'hicolor' / 'scalable' / 'status' / icon 18 | 19 | # Default recipe which runs 'just build-release' 20 | default: build-release 21 | 22 | # Runs 'cargo clean' 23 | clean: 24 | cargo clean 25 | 26 | # Removes vendored dependencies 27 | clean-vendor: 28 | rm -rf .cargo vendor vendor.tar 29 | 30 | # 'cargo clean' and removes vendored dependencies 31 | clean-dist: clean clean-vendor 32 | 33 | # Compiles with debug profile 34 | build *args: 35 | cargo build {{ args }} 36 | 37 | # Compiles with release profile 38 | build-release *args: (build '--release' args) 39 | 40 | # Compiles release profile with vendored dependencies 41 | build-vendored *args: vendor-extract (build-release '--frozen --offline' args) 42 | 43 | # Runs clippy check 44 | check *args: 45 | cargo clippy --all-features {{ args }} -- -W clippy::pedantic 46 | 47 | # Runs a clippy check with JSON message format 48 | check-json: (check '--message-format=json') 49 | 50 | dev *args: 51 | cargo fmt 52 | just run {{ args }} 53 | 54 | # Run with debug logs 55 | run *args: 56 | env RUST_LOG=cosmic_tasks=info RUST_BACKTRACE=full cargo run --release {{ args }} 57 | 58 | # Installs files 59 | install: (build-release) 60 | sudo install -Dm0755 {{ bin-src }} {{ bin-dst }} 61 | sudo install -Dm0644 {{ desktop-src }} {{ desktop-dst }} 62 | sudo install -Dm0644 {{ icons-src }} {{ icons-dst }} 63 | 64 | # Uninstalls installed files 65 | uninstall: 66 | rm {{bin-dst}} 67 | rm {{desktop-dst}} 68 | rm {{icons-dst}} 69 | 70 | # Vendor dependencies only 71 | vendor: 72 | #!/usr/bin/env bash 73 | mkdir -p .cargo 74 | cargo vendor --sync Cargo.toml | head -n -1 > .cargo/config.toml 75 | echo 'directory = "vendor"' >> .cargo/config.toml 76 | echo >> .cargo/config.toml 77 | echo '[env]' >> .cargo/config.toml 78 | if [ -n "${SOURCE_DATE_EPOCH}" ] 79 | then 80 | source_date="$date -d "@${SOURCE_DATE_EPOCH}" "+%Y-%m-%d")" 81 | echo "VERGEN_GIT_COMMIT_DATE = \"${source_date}\"" >> .cargo/config.toml 82 | fi 83 | if [ -n "${SOURCE_GIT_HASH}" ] 84 | then 85 | echo "VERGEN_GIT_SHA = \"${SOURCE_GIT_HASH}\"" >> .cargo/config.toml 86 | fi 87 | tar pcf vendor.tar .cargo vendor 88 | rm -rf .cargo vendor 89 | 90 | # Extract vendored dependencies 91 | vendor-extract: 92 | rm -rf vendor 93 | tar pxf vendor.tar 94 | -------------------------------------------------------------------------------- /src/logic.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::VecDeque, 3 | io::{Error, Read}, 4 | process::{Command, Output, Stdio}, 5 | thread, 6 | time::Duration, 7 | }; 8 | 9 | use regex::RegexBuilder; 10 | 11 | /// Get the IPv4 address assigned to this computer. 12 | pub fn get_tailscale_ip() -> String { 13 | let ip_cmd = Command::new("tailscale") 14 | .args(["ip", "-4"]) 15 | .output() 16 | .unwrap(); 17 | 18 | match String::from_utf8(ip_cmd.stdout) { 19 | Ok(ip) => ip, 20 | Err(e) => format!("Could get tailscale IPv4 address!\n{e}"), 21 | } 22 | } 23 | 24 | /// Get Tailscale's connection status 25 | pub fn get_tailscale_con_status() -> bool { 26 | let con_cmd = Command::new("tailscale") 27 | .args(["debug", "prefs"]) 28 | .stdout(Stdio::piped()) 29 | .spawn(); 30 | 31 | let grep_cmd = Command::new("grep") 32 | .arg("WantRunning") 33 | .stdin(con_cmd.unwrap().stdout.unwrap()) 34 | .output(); 35 | 36 | let con_status = String::from_utf8(grep_cmd.unwrap().stdout).unwrap(); 37 | 38 | con_status.contains("true") 39 | } 40 | 41 | pub fn get_tailscale_devices() -> Vec { 42 | let ts_status_cmd = Command::new("tailscale").arg("status").output(); 43 | 44 | let out = match String::from_utf8(ts_status_cmd.unwrap().stdout) { 45 | Ok(s) => s, 46 | Err(e) => format!("Error getting the status output: {e}"), 47 | }; 48 | // Create a regular expression that finds all of the lines with an ipv4 address 49 | let reg = RegexBuilder::new(r#"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"#) 50 | .build() 51 | .unwrap(); 52 | 53 | let mut status_output: VecDeque = out 54 | .lines() 55 | // Filter out the lines that don't match the ipv4 pattern. 56 | .filter(|line| reg.is_match(line)) 57 | // Map only the device names as elements of the VecDeque 58 | .map(|line| { 59 | line.split_whitespace() 60 | .nth(1) 61 | .expect("Device name not found") 62 | .to_string() 63 | }) 64 | .collect(); 65 | 66 | // Pop this system's device name out of the VecDeque 67 | status_output.pop_front(); 68 | // Add Select as the first element 69 | status_output.push_front("Select".to_string()); 70 | 71 | status_output.to_owned().into() 72 | } 73 | 74 | /// Get the current status of the SSH enablement 75 | pub fn get_tailscale_ssh_status() -> bool { 76 | let ssh_cmd = Command::new("tailscale") 77 | .args(["debug", "prefs"]) 78 | .stdout(Stdio::piped()) 79 | .spawn(); 80 | 81 | let grep_cmd = Command::new("grep") 82 | .arg("RunSSH") 83 | .stdin(ssh_cmd.unwrap().stdout.unwrap()) 84 | .output(); 85 | 86 | let ssh_status = String::from_utf8(grep_cmd.unwrap().stdout).unwrap(); 87 | 88 | ssh_status.contains("true") 89 | } 90 | 91 | /// Get the current status of the accept-routes enablement 92 | pub fn get_tailscale_routes_status() -> bool { 93 | let ssh_cmd = Command::new("tailscale") 94 | .args(["debug", "prefs"]) 95 | .stdout(Stdio::piped()) 96 | .spawn(); 97 | 98 | let grep_cmd = Command::new("grep") 99 | .arg("RouteAll") 100 | .stdin(ssh_cmd.unwrap().stdout.unwrap()) 101 | .output(); 102 | 103 | let ssh_status = String::from_utf8(grep_cmd.unwrap().stdout).unwrap(); 104 | 105 | ssh_status.contains("true") 106 | } 107 | 108 | /// Get available devices 109 | pub fn _get_available_devices() -> String { 110 | let cmd = Command::new("tailscale") 111 | .args(["status", "--active"]) 112 | .output(); 113 | 114 | String::from_utf8(cmd.unwrap().stdout).unwrap() 115 | } 116 | 117 | /// Set the Tailscale connection up/down 118 | pub fn tailscale_int_up(up_down: bool) -> bool { 119 | let mut ret = false; 120 | if up_down { 121 | let _ = Command::new("tailscale").arg("up").output(); 122 | 123 | ret = true; 124 | } else { 125 | let _ = Command::new("tailscale").arg("down").output(); 126 | } 127 | 128 | ret 129 | } 130 | 131 | /// Send files through Tail Drop 132 | /// It's async so that it can be ran in another thread making it 133 | /// non-blocking for the UI. 134 | pub async fn tailscale_send(file_paths: Vec>, target: &str) -> Option { 135 | // A Vec> that holds any error messages that may come back. 136 | let mut status = Vec::>::new(); 137 | 138 | // Loop through the file paths 139 | for path in file_paths.iter() { 140 | // Set a error string variable to be added to the status 141 | let mut err_str = String::new(); 142 | 143 | // Match on the path so Tail Drop can use it to send the file 144 | match path { 145 | // If there is path value 146 | Some(p) => { 147 | // Send the file 148 | let cmd = Command::new("tailscale") 149 | .args(["file", "cp", p, &format!("{target}:")]) 150 | .spawn(); 151 | 152 | // Check for errors from the tailscale command 153 | if let Some(mut err) = cmd.unwrap().stderr { 154 | // Update the err_str variable with the error and continue 155 | // to the next file. 156 | let _ = err.read_to_string(&mut err_str); 157 | continue; 158 | }; 159 | } 160 | // If the path was no good, send an error message back to the UI. 161 | None => { 162 | return Some(String::from( 163 | "Something went wrong sending the file!\nPossible bad file path!", 164 | )) 165 | } 166 | }; 167 | 168 | // If there were an error, add it to the status Vec 169 | if !err_str.is_empty() { 170 | status.push(Some(err_str)); 171 | } 172 | } 173 | 174 | // If we got any errors, let the user know about them. 175 | if !status.is_empty() { 176 | return Some("One or more files were not sent successfully!".to_string()); 177 | } 178 | 179 | None 180 | } 181 | 182 | /// Recieve files through Tail Drop 183 | /// It's async so that it can be ran in another thread making it 184 | /// non-blocking for the UI. 185 | pub async fn tailscale_recieve() -> String { 186 | // Get the username of the current user. 187 | let whoami_cmd = Command::new("whoami").output().unwrap(); 188 | 189 | // Set the username to a variable. 190 | let username = String::from_utf8(whoami_cmd.stdout).unwrap(); 191 | 192 | // Create a path to the user's Downloads directory. 193 | let download_path = &format!("/home/{}/Downloads/", username.trim()); 194 | 195 | // Run the tail drop recieve command, placing the file(s) in the user's Downloads directory. 196 | let rx_cmd = Command::new("tailscale") 197 | .args(["file", "get", download_path]) 198 | .output(); 199 | 200 | // Check to see if there were any errors during the recieve process. 201 | let rx_stderr = rx_cmd.unwrap().stderr.clone(); 202 | 203 | // Either send a success or error message back to the UI. 204 | if rx_stderr.is_empty() { 205 | "Recieved file(s) in Downloads!".to_string() 206 | } else { 207 | String::from_utf8(rx_stderr).unwrap() 208 | } 209 | } 210 | 211 | pub async fn clear_status(wait_time: u64) -> Option { 212 | thread::sleep(Duration::from_secs(wait_time)); 213 | 214 | None 215 | } 216 | 217 | /// Toggle SSH on/off 218 | pub fn set_ssh(ssh: bool) -> bool { 219 | let cmd: Result = if ssh { 220 | Command::new("tailscale").args(["set", "--ssh"]).output() 221 | } else { 222 | Command::new("tailscale") 223 | .args(["set", "--ssh=false"]) 224 | .output() 225 | }; 226 | 227 | match cmd { 228 | Ok(_) => true, 229 | Err(e) => { 230 | eprintln!("Error occurred: {e}"); 231 | false 232 | } 233 | } 234 | } 235 | 236 | /// Toggle accept-routes on/off 237 | pub fn set_routes(accept_routes: bool) -> bool { 238 | let cmd: Result = if accept_routes { 239 | Command::new("tailscale") 240 | .args(["set", "--accept-routes"]) 241 | .output() 242 | } else { 243 | Command::new("tailscale") 244 | .args(["set", "--accept-routes=false"]) 245 | .output() 246 | }; 247 | 248 | match cmd { 249 | Ok(_) => true, 250 | Err(e) => { 251 | eprintln!("Error occurred: {e}"); 252 | false 253 | } 254 | } 255 | } 256 | 257 | // Exit Node Section 258 | 259 | /// Make current host an exit node 260 | pub fn enable_exit_node(is_exit_node: bool) { 261 | let _advertise_cmd = Command::new("tailscale") 262 | .args(["set", &format!("--advertise-exit-node={is_exit_node}")]) 263 | .spawn() 264 | .unwrap(); 265 | 266 | let _ = tailscale_int_up(true); 267 | } 268 | 269 | /// Get the status of whether or not the host is an exit node 270 | pub fn get_is_exit_node() -> bool { 271 | let is_exit_node_cmd = Command::new("tailscale") 272 | .args(["debug", "prefs"]) 273 | .output() 274 | .expect("Failed to run the `tailscale debug prefs` command"); 275 | 276 | let output = String::from_utf8_lossy(&is_exit_node_cmd.stdout).to_string(); 277 | let adv_rts = output 278 | .lines() 279 | .filter(|line| line.to_lowercase().contains("advertiseroutes")) 280 | .flat_map(|line| line.chars()) 281 | .collect::(); 282 | 283 | if adv_rts.contains("null") { 284 | return false; 285 | } 286 | 287 | true 288 | } 289 | 290 | /// Add/remove exit node's access to the host's local LAN 291 | pub fn exit_node_allow_lan_access(is_allowed: bool) -> String { 292 | let allow_lan_access = if is_allowed { "true" } else { "false" }; 293 | 294 | let allow_lan_cmd = Command::new("tailscale") 295 | .args([ 296 | "set", 297 | &format!("--exit-node-allow-lan-access={allow_lan_access}"), 298 | ]) 299 | .spawn(); 300 | 301 | match allow_lan_cmd { 302 | Ok(_) => String::from("Exit node access to LAN allowed!"), 303 | Err(e) => format!("Something went wrong: {e}"), 304 | } 305 | } 306 | 307 | /// Get available exit nodes 308 | pub fn get_avail_exit_nodes() -> Vec { 309 | // Run the tailscale exit-node list command 310 | let exit_node_list_cmd = Command::new("tailscale") 311 | .args(["exit-node", "list"]) 312 | .output(); 313 | 314 | // Get the output String from the command 315 | let exit_node_list_string = String::from_utf8(exit_node_list_cmd.unwrap().stdout).unwrap(); 316 | 317 | // Return if there are no exit nodes 318 | if exit_node_list_string.is_empty() { 319 | println!("No exit nodes found!"); 320 | return vec!["No exit nodes found!".to_string()]; 321 | } 322 | 323 | // Get all of the exit node hostnames out of the output 324 | let fq_hostname_reg = RegexBuilder::new(r#"\w.\w.ts.net"#).build().ok().unwrap(); 325 | let mut exit_node_list: Vec = vec!["None".to_string()]; 326 | 327 | let mut exit_node_map: Vec = exit_node_list_string 328 | .lines() 329 | .filter(|line| fq_hostname_reg.is_match(line)) 330 | .map(|hostname| { 331 | hostname 332 | .split_whitespace() 333 | .nth(1) 334 | .expect("Could not get node fully qualified hostname!") 335 | .split(".") 336 | .next() 337 | .expect("Could not get node hostname!") 338 | .to_string() 339 | }) 340 | .collect(); 341 | 342 | exit_node_list.append(&mut exit_node_map); 343 | 344 | exit_node_list 345 | } 346 | 347 | /// Set selected exit node as the exit node through Tailscale CLI 348 | pub fn set_exit_node(exit_node: String) -> bool { 349 | let _ = Command::new("tailscale") 350 | .args(["set", &format!("--exit-node={exit_node}")]) 351 | .spawn() 352 | .expect("Set exit node was not successful!"); 353 | 354 | exit_node.is_empty() 355 | } 356 | 357 | pub fn switch_accounts(acct_name: String) -> bool { 358 | let cmd = Command::new("tailscale") 359 | .args(["switch", &acct_name]) 360 | .output() 361 | .expect("Failed to run `tailscale switch {acct_name}`"); 362 | 363 | let success = String::from_utf8(cmd.stdout).unwrap(); 364 | 365 | success.to_lowercase().contains("success") 366 | } 367 | 368 | pub fn get_acct_list() -> Vec { 369 | // Run the tailscale swtich --list command 370 | let accts = Command::new("tailscale") 371 | .args(["switch", "--list"]) 372 | .output() 373 | .expect("Failed to run `tailscale switch --list`"); 374 | 375 | // Turn the output into a string 376 | let accts_str = String::from_utf8_lossy(&accts.stdout).to_string(); 377 | 378 | // Filter out the header line 379 | let tailnets = accts_str 380 | .lines() 381 | .filter(|line| !line.to_lowercase().starts_with("id")) 382 | .map(|line| line.to_string()) 383 | .collect::>(); 384 | 385 | // Create a Vec to return the valid accounts in 386 | let mut ret_accts = Vec::new(); 387 | 388 | // Loop through the tailnets Vec that contains the accounts 389 | for acct in tailnets { 390 | // Create a Vec removing all spaces 391 | let accts = acct 392 | .split_whitespace() 393 | .filter(|line| !line.trim().is_empty()) 394 | .map(|acct| acct.to_string()) 395 | .collect::>(); 396 | 397 | // Add the accounts element into the ret_accts Vec 398 | ret_accts.push(accts[1].clone()); 399 | } 400 | 401 | // Return the accounts Vec 402 | ret_accts 403 | } 404 | 405 | pub fn get_current_acct() -> String { 406 | // Run the `tailscale status --json` command 407 | let cmd = Command::new("tailscale") 408 | .args(["status", "--json"]) 409 | .output() 410 | .expect("Failed to run `tailscale status --json` command"); 411 | 412 | // Turn the json output into a big String to be filtered 413 | let output = String::from_utf8_lossy(&cmd.stdout).to_string(); 414 | 415 | // Filter for just the current tailnet name 416 | output 417 | .lines() 418 | .filter(|line| line.trim().starts_with("\"Name\"")) 419 | .map(|line| { 420 | // Remove the double quotes in the returned json 421 | let rep1 = line 422 | .trim() 423 | .split_whitespace() 424 | .last() 425 | .unwrap() 426 | .replace('"', ""); 427 | // Remove the end comma in the returned json 428 | let rep2 = rep1.replace(',', ""); 429 | 430 | // Return the tailscale account name 431 | rep2.trim().to_string() 432 | }) 433 | // Return the current tailnet account name 434 | .collect::>()[0] 435 | .clone() 436 | } 437 | -------------------------------------------------------------------------------- /src/window.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{load_config, update_config}; 2 | use crate::logic::{ 3 | clear_status, enable_exit_node, exit_node_allow_lan_access, get_acct_list, 4 | get_avail_exit_nodes, get_current_acct, get_is_exit_node, get_tailscale_con_status, 5 | get_tailscale_devices, get_tailscale_ip, get_tailscale_routes_status, get_tailscale_ssh_status, 6 | set_exit_node, set_routes, set_ssh, switch_accounts, tailscale_int_up, tailscale_recieve, 7 | tailscale_send, 8 | }; 9 | use cosmic::app::Core; 10 | use cosmic::cosmic_config::Config; 11 | use cosmic::dialog::file_chooser::{self, FileFilter}; 12 | use cosmic::iced::{ 13 | alignment::Horizontal, 14 | platform_specific::shell::commands::popup::{destroy_popup, get_popup}, 15 | widget::{column, horizontal_space, row}, 16 | window::Id, 17 | Alignment, Length, Limits, 18 | }; 19 | use cosmic::iced_runtime::core::window; 20 | use cosmic::iced_widget::Row; 21 | use cosmic::widget::{ 22 | button, dropdown, list_column, 23 | settings::{self}, 24 | text, toggler, 25 | }; 26 | use cosmic::{Action, Element, Task}; 27 | use std::fmt::Debug; 28 | use std::path::PathBuf; 29 | use url::Url; 30 | 31 | const ID: &str = "com.github.bhh32.GUIScaleApplet"; 32 | const CONFIG_VERS: u64 = 1; 33 | const DEFAULT_EXIT_NODE: &str = "Select Exit Node"; 34 | const POPUP_MAX_WIDTH: f32 = 720.0; 35 | const POPUP_MIN_WIDTH: f32 = 640.0; 36 | const POPUP_MAX_HEIGHT: f32 = 1080.0; 37 | const POPUP_MIN_HEIGHT: f32 = 200.0; 38 | const STATUS_CLEAR_TIME: u64 = 5; 39 | 40 | /// Holds the applet's state 41 | pub struct Window { 42 | core: Core, 43 | config: Config, 44 | popup: Option, 45 | ssh: bool, 46 | routes: bool, 47 | connect: bool, 48 | device_options: Vec, 49 | selected_device: String, 50 | selected_device_idx: Option, 51 | send_files: Vec>, 52 | send_file_status: String, 53 | files_sent: bool, 54 | recieve_file_status: String, 55 | avail_exit_nodes: Vec, 56 | sel_exit_node: String, 57 | sel_exit_node_idx: Option, 58 | acct_list: Vec, 59 | cur_acct: String, 60 | allow_lan: bool, 61 | is_exit_node: bool, 62 | } 63 | 64 | /// Messages to be sent to the Libcosmic Update function 65 | #[derive(Clone, Debug)] 66 | pub enum Message { 67 | TogglePopup, 68 | PopupClosed(Id), 69 | EnableSSH(bool), 70 | AcceptRoutes(bool), 71 | ConnectDisconnect(bool), 72 | SwitchAccount(usize), 73 | DeviceSelected(usize), 74 | ChooseFiles, 75 | FilesSelected(Vec), 76 | SendFiles, 77 | FilesSent(Option), 78 | FileChoosingCancelled, 79 | RecieveFiles, 80 | FilesRecieved(String), 81 | ExitNodeSelected(usize), 82 | AllowExitNodeLanAccess(bool), 83 | UpdateIsExitNode(bool), 84 | ClearTailDropStatus, 85 | } 86 | 87 | impl cosmic::Application for Window { 88 | type Executor = cosmic::executor::multi::Executor; 89 | type Flags = (); 90 | type Message = Message; 91 | const APP_ID: &'static str = ID; 92 | 93 | fn core(&self) -> &Core { 94 | &self.core 95 | } 96 | 97 | fn core_mut(&mut self) -> &mut Core { 98 | &mut self.core 99 | } 100 | 101 | fn init(core: Core, _flags: Self::Flags) -> (Window, Task>) { 102 | // Get the SSH status from the Tailscale CLI 103 | let ssh = get_tailscale_ssh_status(); 104 | // Get the Accept Routes status from the Tailscale CLI 105 | let routes = get_tailscale_routes_status(); 106 | // Get the connection status from the Tailscale CLI 107 | let connect = get_tailscale_con_status(); 108 | // Get the other devices on the Tailnet from the Tailscale CLI 109 | let device_options = get_tailscale_devices(); 110 | 111 | // Set the default applet state for allow_lan to false 112 | let allow_lan = false; 113 | // Get the state of the host being an exit node from the Tailscale CLI 114 | let is_exit_node = get_is_exit_node(); 115 | 116 | // Get the list of accounts the device is registered on 117 | let acct_list = get_acct_list(); 118 | 119 | // Get which account the device is currently logged into 120 | let cur_acct = get_current_acct(); 121 | 122 | // Check to see if the host is an exit node already. 123 | // If it's not, get the available exit nodes. 124 | // If it is, set exit_nodes_init to the messag. 125 | let exit_nodes_init = if !is_exit_node { 126 | get_avail_exit_nodes() 127 | } else { 128 | vec![String::from( 129 | "Can't select an exit node\nwhile host is an exit node!", 130 | )] 131 | }; 132 | 133 | // Set the start up state of the application using the above variables 134 | let mut window = Window { 135 | core, 136 | config: Config::new(ID, CONFIG_VERS).unwrap(), 137 | ssh, 138 | routes, 139 | connect, 140 | device_options, 141 | popup: None, 142 | selected_device: DEFAULT_EXIT_NODE.to_string(), 143 | selected_device_idx: Some(0), 144 | send_files: Vec::>::new(), 145 | send_file_status: String::new(), 146 | files_sent: false, 147 | recieve_file_status: String::new(), 148 | avail_exit_nodes: exit_nodes_init, 149 | sel_exit_node: DEFAULT_EXIT_NODE.to_string(), 150 | sel_exit_node_idx: None, 151 | acct_list, 152 | cur_acct, 153 | allow_lan, 154 | is_exit_node, 155 | }; 156 | 157 | // Set the exit node index state from the config file 158 | window.sel_exit_node_idx = match load_config("exit-node", CONFIG_VERS) { 159 | (Some(val), _) => Some(val), 160 | (None, err_str) => { 161 | eprintln!("{err_str}"); 162 | None 163 | } 164 | }; 165 | 166 | // Set the allow lan state from the config file 167 | window.allow_lan = match load_config("allow-lan", CONFIG_VERS) { 168 | (Some(val), _) => val, 169 | (None, err_str) => { 170 | eprintln!("{err_str}"); 171 | false 172 | } 173 | }; 174 | 175 | // Return the state and no Task 176 | (window, Task::none()) 177 | } 178 | 179 | // The function that is called when the applet is closed 180 | fn on_close_requested(&self, id: window::Id) -> Option { 181 | Some(Message::PopupClosed(id)) 182 | } 183 | 184 | // Libcosmic's update function 185 | fn update(&mut self, message: Self::Message) -> Task> { 186 | match message { 187 | Message::TogglePopup => { 188 | return if let Some(p) = self.popup.take() { 189 | self.recieve_file_status = String::new(); 190 | destroy_popup(p) 191 | } else { 192 | let new_id = Id::unique(); 193 | self.popup.replace(new_id); 194 | 195 | let mut popup_settings = self.core.applet.get_popup_settings( 196 | self.core.main_window_id().unwrap(), 197 | new_id, 198 | None, 199 | None, 200 | None, 201 | ); 202 | 203 | popup_settings.positioner.size_limits = Limits::NONE 204 | .max_width(POPUP_MAX_WIDTH) 205 | .min_width(POPUP_MIN_WIDTH) 206 | .min_height(POPUP_MIN_HEIGHT) 207 | .max_height(POPUP_MAX_HEIGHT); 208 | 209 | get_popup(popup_settings) 210 | } 211 | } 212 | Message::PopupClosed(id) => { 213 | if self.popup.as_ref() == Some(&id) { 214 | self.popup = None; 215 | } 216 | } 217 | Message::EnableSSH(enabled) => { 218 | self.ssh = enabled; 219 | set_ssh(self.ssh); 220 | } 221 | Message::AcceptRoutes(accepted) => { 222 | self.routes = accepted; 223 | set_routes(self.routes); 224 | } 225 | Message::ConnectDisconnect(connection) => { 226 | self.connect = connection; 227 | tailscale_int_up(self.connect); 228 | } 229 | Message::SwitchAccount(new_acct) => { 230 | self.cur_acct = self.acct_list[new_acct].clone(); 231 | switch_accounts(self.cur_acct.clone()); 232 | 233 | self.ssh = get_tailscale_ssh_status(); 234 | set_ssh(self.ssh); 235 | self.routes = get_tailscale_routes_status(); 236 | set_routes(self.routes); 237 | self.device_options = get_tailscale_devices(); 238 | self.avail_exit_nodes = get_avail_exit_nodes(); 239 | } 240 | Message::DeviceSelected(device) => { 241 | self.selected_device = self.device_options[device].clone(); 242 | self.selected_device_idx = Some(device); 243 | 244 | if self.files_sent { 245 | self.files_sent = false; 246 | } 247 | } 248 | Message::ChooseFiles => { 249 | return cosmic::task::future(async move { 250 | let file_filter = FileFilter::new("Any").glob("*.*"); 251 | let dialog = file_chooser::open::Dialog::new() 252 | .title("Choose a file or files...") 253 | .filter(file_filter); 254 | 255 | let msg = match dialog.open_files().await { 256 | Ok(file_responses) => { 257 | Message::FilesSelected(file_responses.urls().to_vec()) 258 | } 259 | Err(file_chooser::Error::Cancelled) => Message::FileChoosingCancelled, 260 | Err(e) => { 261 | eprintln!("Choosing a file or files went wrong: {e}"); 262 | Message::FileChoosingCancelled 263 | } 264 | }; 265 | 266 | msg 267 | }); 268 | } 269 | Message::FilesSelected(urls) => { 270 | for url in urls.iter() { 271 | let path = match url.to_file_path() { 272 | Ok(good_path) => good_path, 273 | Err(_e) => PathBuf::new(), 274 | }; 275 | 276 | if path.exists() { 277 | self.send_files.push(Some(match path.as_path().to_str() { 278 | Some(f_path) => String::from(f_path), 279 | None => String::new(), 280 | })); 281 | } 282 | } 283 | 284 | // Set the files sent flag to false. 285 | self.files_sent = false; 286 | 287 | // Use the same popup logic as TogglePopup to keep the applet open 288 | // after selecting the files. 289 | // Note: It won't let you just call Message::TogglePopup here. 290 | let new_id = Id::unique(); 291 | self.popup.replace(new_id); 292 | 293 | let mut popup_settings = self.core.applet.get_popup_settings( 294 | self.core.main_window_id().unwrap(), 295 | new_id, 296 | None, 297 | None, 298 | None, 299 | ); 300 | 301 | popup_settings.positioner.size_limits = Limits::NONE 302 | .max_width(POPUP_MAX_WIDTH) 303 | .min_width(POPUP_MIN_WIDTH) 304 | .min_height(POPUP_MIN_HEIGHT) 305 | .max_height(POPUP_MAX_HEIGHT); 306 | 307 | return get_popup(popup_settings); 308 | } 309 | Message::SendFiles => { 310 | // Get the file(s) that are being sent 311 | let files = self.send_files.clone(); 312 | // Get the device name that the files are being sent to 313 | let dev = self.selected_device.clone(); 314 | 315 | // Make sure that the device is not the Select choice 316 | if dev != "Select" { 317 | self.files_sent = true; 318 | // Use the async command to use a new thread 319 | return cosmic::task::future(async move { 320 | // Send the file(s) and return the transfer status when the transfer is complete 321 | 322 | // Status clearing bug starts here. Unsure why this doesn't wait for the status to return before 323 | // sending the FilesSent message. 324 | let tx_status = (tailscale_send(files, &dev)).await; 325 | 326 | // When the file(s) are done being sent, send the FilesSent message to the update function 327 | Message::FilesSent(tx_status) 328 | }); 329 | } 330 | } 331 | Message::FilesSent(tx_status) => { 332 | println!("tx_status: {tx_status:?}"); 333 | // Once the files are sent: 334 | // 1. Set the send file status to the transfer status 335 | self.send_file_status = match tx_status { 336 | Some(err_val) => err_val, 337 | None => String::from("File(s) sent successfully!"), 338 | }; 339 | 340 | if !self.send_file_status.is_empty() { 341 | if !self.send_files.is_empty() { 342 | // 2. Clear the selected files that were just sent from the vector 343 | self.send_files.clear(); 344 | } 345 | 346 | // Create a task in a separate thread that clears the TailDrop status after a designated amount of time. 347 | return cosmic::task::future(async move { Message::ClearTailDropStatus }); 348 | } 349 | } 350 | Message::FileChoosingCancelled => { 351 | // Use the same popup logic as TogglePopup to keep the applet open 352 | // after selecting the files. 353 | // Note: It won't let you just call Message::TogglePopup here. 354 | let new_id = Id::unique(); 355 | self.popup.replace(new_id); 356 | 357 | let mut popup_settings = self.core.applet.get_popup_settings( 358 | self.core.main_window_id().unwrap(), 359 | new_id, 360 | None, 361 | None, 362 | None, 363 | ); 364 | 365 | popup_settings.positioner.size_limits = Limits::NONE 366 | .max_width(POPUP_MAX_WIDTH) 367 | .min_width(POPUP_MIN_WIDTH) 368 | .min_height(POPUP_MIN_HEIGHT) 369 | .max_height(POPUP_MAX_HEIGHT); 370 | 371 | return get_popup(popup_settings); 372 | } 373 | Message::RecieveFiles => { 374 | // Run the recieve function in a separate thread so it doesn't block the current thread. 375 | return cosmic::task::future(async move { 376 | let rx_status = tailscale_recieve().await; 377 | Message::FilesRecieved(rx_status) 378 | }); 379 | } 380 | Message::FilesRecieved(rx_status) => { 381 | self.recieve_file_status = rx_status; 382 | 383 | if !self.recieve_file_status.is_empty() { 384 | // Create a task in a separate thread that clears the TailDrop status after a designated amount of time. 385 | return cosmic::task::future(async move { Message::ClearTailDropStatus }); 386 | } 387 | } 388 | Message::ExitNodeSelected(exit_node) => { 389 | if !self.is_exit_node { 390 | // Set the model's selected exit node 391 | self.sel_exit_node = self.avail_exit_nodes[exit_node].clone(); 392 | self.sel_exit_node_idx = Some(exit_node); 393 | 394 | // Use that exit node 395 | if exit_node == 0 { 396 | set_exit_node(String::new()); 397 | } else { 398 | set_exit_node(self.sel_exit_node.clone()); 399 | } 400 | 401 | // Set the config_entry to the exit node 402 | update_config( 403 | self.config.clone(), 404 | "exit-node", 405 | match self.sel_exit_node_idx { 406 | Some(idx) => idx, 407 | None => { 408 | eprintln!("Could not update the config file!"); 409 | 0 410 | } 411 | }, 412 | ); 413 | } 414 | } 415 | Message::AllowExitNodeLanAccess(allow_lan_access) => { 416 | self.allow_lan = allow_lan_access; 417 | 418 | // Double check that is_exit_node is true 419 | if self.is_exit_node { 420 | // Set the host exit node to allow lan access 421 | let _status = exit_node_allow_lan_access(self.allow_lan); 422 | // Update the configuration file, allow-lan 423 | update_config(self.config.clone(), "allow-lan", self.allow_lan); 424 | } 425 | } 426 | Message::UpdateIsExitNode(is_exit_node) => { 427 | // Ensure we're not using some other exit node 428 | if self.sel_exit_node_idx == Some(0) { 429 | // Set the model is_exit_node to the message is_exit_node 430 | self.is_exit_node = is_exit_node; 431 | 432 | // Enable/disable this host as an exit node 433 | enable_exit_node(self.is_exit_node); 434 | 435 | self.avail_exit_nodes = get_avail_exit_nodes(); 436 | } 437 | } 438 | Message::ClearTailDropStatus => { 439 | // Clear the files recieved status in the status clear time 440 | if !self.recieve_file_status.is_empty() { 441 | // Done in a separate thread as to not block the current thread. 442 | return cosmic::task::future(async move { 443 | Message::FilesRecieved(match clear_status(STATUS_CLEAR_TIME).await { 444 | Some(bad_value) => format!("Something went wrong and clear status returned a value: {bad_value}"), 445 | None => String::new(), 446 | }) 447 | }); 448 | // Clear the send files status in the status clear time 449 | } else if !self.send_file_status.is_empty() || self.files_sent { 450 | println!("Entered clear tail drop status message send"); 451 | // 4. Reset the selected_device_idx back to 0 (Selected) 452 | self.selected_device_idx = Some(0); 453 | // 5. Reset the selected_device back to Selected 454 | self.selected_device = self.device_options[0].clone(); 455 | 456 | // Done in a separate thread as to not block the current thread. 457 | return cosmic::task::future(async move { 458 | Message::FilesSent(match clear_status(STATUS_CLEAR_TIME).await { 459 | Some(bad_value) => Some(format!("Something went wrong and clear status returned a value: {bad_value}")), 460 | None => Some(String::new()), 461 | }) 462 | }); 463 | } 464 | } 465 | } 466 | Task::none() 467 | } 468 | 469 | // Libcosmic's view function 470 | fn view(&self) -> Element<'_, Self::Message> { 471 | self.core 472 | .applet 473 | // Set the icon button to the tailscale-icon defined during installation. 474 | .icon_button("tailscale-icon") 475 | .on_press(Message::TogglePopup) 476 | .into() 477 | } 478 | 479 | // Libcosmic's applet view_window function 480 | fn view_window(&self, _id: Id) -> Element<'_, Self::Message> { 481 | // Normal status elements 482 | let cur_acct = &self.cur_acct; 483 | let acct_list = &self.acct_list; 484 | let ip = get_tailscale_ip(); 485 | 486 | // Get the current account index 487 | let mut sel_acct_idx = None; 488 | for (idx, acct) in acct_list.iter().enumerate() { 489 | if acct == cur_acct { 490 | sel_acct_idx = Some(idx); 491 | break; 492 | } 493 | } 494 | 495 | let conn_status = get_tailscale_con_status(); 496 | 497 | let status_elements: Vec> = vec![ 498 | (Element::from(column!( 499 | row!(settings::item( 500 | "Account", 501 | dropdown(acct_list, sel_acct_idx, Message::SwitchAccount) 502 | )), 503 | row!(settings::item("Tailscale Address", text(ip.clone()),)), 504 | row!(settings::item( 505 | "Connection Status", 506 | text(if conn_status { 507 | "Tailscale Connected" 508 | } else { 509 | "Tailscale Disconnected" 510 | }) 511 | )), 512 | ))), 513 | ]; 514 | 515 | let status_row = Row::with_children(status_elements) 516 | .align_y(Alignment::Center) 517 | .spacing(0); 518 | 519 | // Enable/Disable Elements (ssh, routes) 520 | let enable_elements: Vec> = vec![ 521 | (Element::from( 522 | column!( 523 | row!(settings::item( 524 | "Enable SSH", 525 | toggler(self.ssh).on_toggle(Message::EnableSSH) 526 | )), 527 | row!(settings::item( 528 | "Accept Routes", 529 | toggler(self.routes).on_toggle(Message::AcceptRoutes) 530 | )), 531 | ) 532 | .spacing(5), 533 | )), 534 | ]; 535 | 536 | let enable_row = Row::with_children(enable_elements); 537 | 538 | // File tx/rx elements 539 | let taildrop_elements: Vec> = vec![Element::from( 540 | column!( 541 | row!(text("Tail Drop")).align_y(Alignment::Center), 542 | row!( 543 | column!(dropdown( 544 | &self.device_options, 545 | self.selected_device_idx, 546 | Message::DeviceSelected 547 | ) 548 | .width(110),) 549 | .align_x(Horizontal::Left) 550 | .padding(5), 551 | horizontal_space().width(Length::Fill), 552 | column!(button::standard("Select File(s)") 553 | .on_press(Message::ChooseFiles) 554 | .width(220) 555 | .tooltip("Select the file(s) to send.")) 556 | .align_x(Horizontal::Right) 557 | .padding(5) 558 | ) 559 | .align_y(Alignment::Center) 560 | .spacing(25), 561 | row!( 562 | column!(if !self.send_files.is_empty() { 563 | button::standard("Send File(s)") 564 | .on_press(Message::SendFiles) 565 | .width(110) 566 | .tooltip("Send the selected file(s).") 567 | } else { 568 | button::standard("Send File(s)") 569 | .width(110) 570 | .tooltip("Send the selected file(s).") 571 | }) 572 | .align_x(Horizontal::Left) 573 | .padding(5), 574 | horizontal_space().width(Length::Fill), 575 | column!(button::standard("Recieve File(s)") 576 | .on_press(Message::RecieveFiles) 577 | .width(220) 578 | .tooltip("Recieve files waiting in the Tail Drop inbox.")) 579 | .align_x(Horizontal::Right) 580 | .padding(5) 581 | ) 582 | .align_y(Alignment::Center) 583 | .spacing(25) 584 | ) 585 | .align_x(Alignment::Center), 586 | )]; 587 | 588 | let taildrop_row = Row::with_children(taildrop_elements); 589 | // File tx/rx status elements 590 | let taildrop_status_elements: Vec> = vec![ 591 | (Element::from(column!( 592 | row!(text("Send/Recieve Status") 593 | .width(Length::Fill) 594 | .align_x(Horizontal::Center)) 595 | .height(30) 596 | .align_y(Alignment::Center), 597 | row!(if !self.send_file_status.is_empty() { 598 | text(self.send_file_status.clone()) 599 | } else if self.files_sent && self.selected_device != *"Select" { 600 | text("File(s) were sent successfully!") 601 | } else if self.selected_device == *"Select" && !self.files_sent { 602 | text("Choose a device first,\nthen reselect your file(s)!") 603 | } else { 604 | text("") 605 | }), 606 | row!(text(self.recieve_file_status.clone())) 607 | ))), 608 | ]; 609 | 610 | let tx_rx_status_row = Row::with_children(taildrop_status_elements); 611 | 612 | // Exit node UI elements 613 | // Using the config file to see if there is an external exit node set 614 | let (config_exit_node, _err): (Option, String) = 615 | load_config("exit-node", CONFIG_VERS); 616 | 617 | // Create element Vector for the exit node elements 618 | let mut exit_node_elements: Vec> = Vec::new(); 619 | 620 | let host_exit_node_col = column!( 621 | Element::from(if config_exit_node == Some(0) { 622 | if !self.is_exit_node { 623 | toggler(self.is_exit_node) 624 | .label("Enable Host Exit Node") 625 | .on_toggle(Message::UpdateIsExitNode) 626 | } else { 627 | toggler(self.is_exit_node) 628 | .label("Disable Host Exit Node") 629 | .on_toggle(Message::UpdateIsExitNode) 630 | } 631 | } else { 632 | toggler(self.is_exit_node).label("Enable Host Exit Node") 633 | }), 634 | Element::from(if self.is_exit_node { 635 | toggler(self.allow_lan) 636 | .label("Allow LAN Access") 637 | .on_toggle(Message::AllowExitNodeLanAccess) 638 | } else { 639 | toggler(self.allow_lan).label("Allow LAN Access") 640 | }) 641 | ) 642 | .spacing(5) 643 | .align_x(Alignment::Start); 644 | 645 | exit_node_elements.push(Element::from( 646 | column!( 647 | row!( 648 | // Section title 649 | text("Exit Node") 650 | .width(Length::Fill) 651 | .align_x(Horizontal::Center) 652 | ), 653 | row!( 654 | column!( 655 | // Exit node selection dropdown 656 | column!( 657 | text("Selected Node") 658 | .align_x(Alignment::Start) 659 | .align_y(Alignment::Center), 660 | dropdown( 661 | &self.avail_exit_nodes, 662 | self.sel_exit_node_idx, 663 | Message::ExitNodeSelected 664 | ) 665 | .width(125) 666 | ) 667 | .align_x(Alignment::Center) 668 | ) 669 | .padding(15) 670 | .align_x(Alignment::Center), 671 | column!( 672 | // Use the config exit node setting to enable/disable the host's exit node toggler. 673 | host_exit_node_col 674 | ) 675 | .padding(15) 676 | ) 677 | ) 678 | .spacing(10) 679 | .align_x(Alignment::Center), 680 | )); 681 | 682 | let exit_node_row = Row::with_children(exit_node_elements); 683 | 684 | let content_list = list_column() 685 | .padding(5) 686 | .spacing(0) 687 | .add(Element::from(status_row)) 688 | .add(Element::from(enable_row)) 689 | .add(settings::item( 690 | "Connected", 691 | toggler(self.connect).on_toggle(Message::ConnectDisconnect), 692 | )) 693 | .add(Element::from(taildrop_row)) 694 | .add(Element::from(tx_rx_status_row)) 695 | .add(Element::from(exit_node_row)); 696 | 697 | self.core.applet.popup_container(content_list).into() 698 | } 699 | } 700 | --------------------------------------------------------------------------------