├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── build.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── betterctl.sh └── src ├── better_control.py ├── control.desktop ├── models ├── bluetooth_device.py └── wifi_network.py ├── tools ├── bluetooth.py ├── display.py ├── globals.py ├── hyprland.py ├── network.py ├── notify.py ├── swaywm.py ├── system.py ├── terminal.py ├── volume.py └── wifi.py ├── ui ├── css │ ├── __init__.py │ ├── animations.css │ └── animations.py ├── dialogs │ └── rotation_dialog.py ├── main_window.py ├── tabs │ ├── autostart_tab.py │ ├── battery_tab.py │ ├── bluetooth_tab.py │ ├── display_tab.py │ ├── power_tab.py │ ├── settings_tab.py │ ├── usbguard_tab.py │ ├── volume_tab.py │ └── wifi_tab.py └── widgets │ ├── bluetooth_device_row.py │ └── wifi_network_row.py └── utils ├── arg_parser.py ├── dependencies.py ├── hidden_devices.py ├── logger.py ├── pair.py ├── settings.py ├── translations.py └── usbguard_permissions.sh /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe. If not and is more open ended then use discussions** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | build: 10 | name: "Build Better Control" 11 | runs-on: ubuntu-latest 12 | container: 13 | image: archlinux 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Install dependencies 17 | shell: bash 18 | run: | 19 | sed -i 's/SigLevel = Required DatabaseOptional/SigLevel = Optional TrustAll/' /etc/pacman.conf 20 | pacman -Syyu --noconfirm --noprogressbar --needed base-devel git python 21 | python -m venv .venv 22 | 23 | - name: Compile 24 | run: | 25 | source .venv/bin/activate 26 | python -m py_compile src/better_control.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.venv 2 | **/__pycache__/ 3 | /.vscode 4 | /.pytest_cache 5 | /log.txt -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Features 4 | 5 | Feel free to propose PR and suggest new features, improvements. 6 | 7 | ## Translations 8 | 9 | If you wish to contribute with translation for the app into your language, please see the `utils/translations.py` file. if you have any doubts on it feel free to open a discussion or issue 10 | 11 | step 1 : adding translations in `utils/translations.py` 12 | 13 | step 2 : goto `src/better_control.py` and add your langauge as follows by editing this part 14 | 15 | ``` 16 | def load_language_and_translations(arg_parser, logger): 17 | 18 | settings = load_settings(logger) 19 | 20 | available_languages = ["en", "es", "pt", "fr", "id", "it", "tr"] --> add here 21 | 22 | 23 | 24 | def process_language(arg_parser, logger): 25 | 26 | settings = load_settings(logger) 27 | 28 | available_languages = ["en", "es", "pt", "fr", "id", "it", "tr"] --> add here 29 | 30 | 31 | ``` 32 | 33 | step 3 : then in `src/ui/tabs/settings.py` edit this part 34 | ``` 35 | lang_combo.append("pt", "Português") 36 | lang_combo.append("fr", "Français") 37 | lang_combo.append("id", "Bahasa Indonesia") 38 | lang_combo.append("it", "Italian") 39 | lang_combo.append("tr", "Turkish") 40 | lang_combo.append("add your new language here") 41 | 42 | ``` 43 | 44 | step 4 : change to new version 45 | 46 | goto `betterctl.sh` and `src/ui/main_window.py` and change the old version to new 47 | eg: `6.11.6` --> `6.11.7` 48 | 49 | > If you cant find the line with the version just search for the last version and you can find it 50 | 51 | 52 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Better Control 2 | # This Makefile installs the application to /usr/share/better-control 3 | # and creates launcher scripts in /usr/bin 4 | 5 | PREFIX ?= /usr 6 | INSTALL_DIR = $(DESTDIR)$(PREFIX)/share/better-control 7 | BIN_DIR = $(DESTDIR)$(PREFIX)/bin 8 | 9 | .PHONY: all install uninstall clean 10 | 11 | all: 12 | @echo "Run 'make install' to install Better Control" 13 | 14 | install: 15 | @echo "Installing Better Control..." 16 | # Create installation directories 17 | mkdir -p $(INSTALL_DIR) 18 | mkdir -p $(BIN_DIR) 19 | # Copy all project files to installation directory 20 | cp -r src/* $(INSTALL_DIR)/ 21 | 22 | # Create and install the better-control executable script 23 | @echo "#!/bin/bash" > better-control 24 | @echo "python3 $(PREFIX)/share/better-control/better_control.py \$$@" >> better-control 25 | chmod +x better-control 26 | cp better-control $(BIN_DIR)/better-control 27 | 28 | # Create and install the control executable script 29 | cp better-control control 30 | cp control $(BIN_DIR)/control 31 | cp betterctl.sh $(BIN_DIR)/betterctl 32 | chmod +x $(BIN_DIR)/betterctl 33 | 34 | # Cleanup temporary files 35 | rm better-control control 36 | 37 | # Install desktop file 38 | mkdir -p $(DESTDIR)$(PREFIX)/share/applications 39 | sed 's|Exec=/usr/bin/control|Exec=/usr/bin/better-control|' \ 40 | src/control.desktop > $(DESTDIR)$(PREFIX)/share/applications/better-control.desktop 41 | 42 | @echo "Installation complete!" 43 | 44 | uninstall: 45 | @echo "Uninstalling Better Control..." 46 | rm -rf $(INSTALL_DIR) 47 | rm -f $(BIN_DIR)/better-control 48 | rm -f $(BIN_DIR)/control 49 | rm -f $(BIN_DIR)/betterctl 50 | rm -f $(DESTDIR)$(PREFIX)/share/applications/better-control.desktop 51 | @echo "Uninstallation complete!" 52 | 53 | clean: 54 | @echo "Nothing to clean." -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # ⚙️ Better Control 4 | 5 | 6 | 7 | ### *A sleek GTK-themed control panel for Linux* 🐧 8 | 9 | [![AUR Package](https://img.shields.io/badge/AUR-better--control--git-429768?style=flat-square&logo=archlinux&logoColor=white&labelColor=444)](https://aur.archlinux.org/packages/better-control-git) 10 | [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-429768.svg?style=flat-square&logo=github&labelColor=444)](LICENSE) 11 | [![GitHub stars](https://img.shields.io/github/stars/better-ecosystem/better-control?style=flat-square&color=429768&logo=starship&labelColor=444)](https://github.com/better-ecosystem/better-control/stargazers) 12 | [![Latest Release](https://img.shields.io/github/v/release/better-ecosystem/better-control.svg?style=flat-square&color=429768&logo=speedtest&label=latest-release&labelColor=444)](https://github.com/better-ecosystem/better-control/releases/latest) 13 | 14 |
15 | 16 | --- 17 | 18 | > [!IMPORTANT] 19 | > 🚧 This project is under active development. Contributions, feature requests, ideas, and testers are welcome! 20 | 21 | --- 22 | 23 | ## ✨ Features 24 | 25 | - 🔄 Seamless integration with your desktop environment 26 | - 📱 Modern, clean interface for system controls 27 | - 🎚️ Quick access to common system settings and tons of features 28 | - 🌙 Respects your system's light/dark theme settings 29 | - 🧩 Modular design - use only what you need and remove the ones you don't use (see dependencies for more info) 30 | 31 |
32 | Dependencies 33 | 34 | Before installing, ensure you have `git` and `base-devel` installed. 35 | 36 | ### Core Dependencies 37 | 38 | | Dependency | Purpose | 39 | |------------|---------| 40 | | **GTK 3** | UI framework | 41 | | **Python Libraries** | python-gobject, python-dbus, python-psutil, python-setproctitle | 42 | 43 | ### Feature-Specific Dependencies 44 | 45 | | Feature | Required Packages | 46 | |---------|------------------| 47 | | **Wi-Fi Management** | NetworkManager, python-qrcode | 48 | | **Bluetooth** | BlueZ & BlueZ Utils | 49 | | **Audio Control** | PipeWire or PulseAudio | 50 | | **Brightness** | brightnessctl | 51 | | **Power Management** | power-profiles-daemon, upower | 52 | | **Blue Light Filter** | gammastep | 53 | | **USBGuard** | USBGuard | 54 | | **Pillow** | For QR Code on Wi-Fi | 55 | 56 | > [!TIP] 57 | > If you don't need a specific feature, you can safely omit its corresponding dependency and hide its tab in the settings. 58 | 59 |
60 | 61 | --- 62 | 63 | ## 💾 Installation & Uninstallation 64 | 65 | ### 🚀 Quick Install (Recommended) 66 | 67 | The easiest way to install Better Control is using our automated installer script: 68 | 69 | ``` 70 | curl -fsSL https://raw.githubusercontent.com/better-ecosystem/better-control/refs/heads/main/betterctl.sh -o /tmp/betterctl.sh && bash /tmp/betterctl.sh && rm /tmp/betterctl.sh 71 | ``` 72 | 73 | **What this script does:** 74 | - Uses [AUR](https://aur.archlinux.org/packages/better-control-git) for Arch-based distributions 75 | - Uses [Makefile](https://github.com/better-ecosystem/better-control/blob/main/Makefile) for other distributions 76 | - Automatically installs all required dependencies 77 | 78 | > [!TIP] 79 | > **Security conscious?** You can review the installer script [here](https://raw.githubusercontent.com/better-ecosystem/better-control/refs/heads/main/betterctl.sh) before running it. 80 | 81 | **Supported Distributions:** 82 | - 🔵 Arch-based (Arch, Manjaro, EndeavourOS, etc.) 83 | - 🟠 Debian-based (Ubuntu, Linux Mint, Pop!_OS, etc.) 84 | - 🔴 Fedora-based (Fedora, openSUSE, etc.) 85 | - 🟢 Void Linux 86 | - 🏔️ Alpine Linux 87 | 88 | ### 🔧 Manual Installation 89 | 90 | For users who prefer manual installation or need more control over the process: 91 | 92 | ```bash 93 | git clone https://github.com/better-ecosystem/better-control 94 | cd better-control 95 | sudo make install 96 | ``` 97 | 98 | **For Arch Linux users**, Better Control is also available on the AUR: 99 | 100 | ```bash 101 | yay -S better-control-git 102 | ``` 103 | 104 | > [!IMPORTANT] 105 | > When building manually, ensure you have all [dependencies](#dependencies) installed beforehand. 106 | 107 |
108 | ➡️ Void Linux 109 | 110 | Better Control is available in the official Void Linux repository. 111 | 112 | ```bash 113 | xbps-install -S better-control 114 | ``` 115 |
116 | 117 |
118 | ➡️ Nix/NixOS (Distro Independent) (Unofficial) 119 | 120 | Better Control is available in the `nixpkgs` repository. 121 | 122 | **On NixOS:** 123 | 124 | ```bash 125 | nix-env -iA nixos.better-control 126 | ``` 127 | 128 | **On Non-NixOS:** 129 | 130 | ```bash 131 | # without flakes: 132 | nix-env -iA nixpkgs.better-control 133 | # with flakes: 134 | nix profile install nixpkgs#better-control 135 | ``` 136 | 137 | ⚠️ **Bleeding edge (Unstable):** This flake will update to the latest commit automatically: [Better Control Flake](https://github.com/Rishabh5321/better-control-flake) 138 | 139 |
140 | 141 | ### 🔄 Updates & Uninstallation 142 | 143 | After installation, you can manage Better Control using the `betterctl` command: 144 | 145 | ```bash 146 | betterctl # Interactive menu for update/uninstall options 147 | ``` 148 | 149 | --- 150 | 151 | ## 🫴 Usage 152 | 153 | Use the `control` or `better-control` command to run the GUI application. Use `control --help` or `better-control --help` to see more specific launch commands you can use with tools like waybar. 154 | 155 | You can use `betterctl` to update or uninstall the application. 156 | 157 | ### Keybindings 158 | 159 | | Keybinding | Action | 160 | |------------|--------| 161 | | `Shift + S` | Open Settings Dialog | 162 | | `Q` or `Ctrl + Q` | Quit Application | 163 | 164 | --- 165 | 166 | ## 📚 Contribution 167 | 168 | If you want to contribute, see [CONTRIBUTING.md](https://github.com/better-ecosystem/better-control/blob/main/CONTRIBUTING.md) 169 | 170 | ## 📄 License 171 | 172 | This project is licensed under the GNU General Public License v3.0. See the [LICENSE](LICENSE) for more details. 173 | 174 | --- 175 | 176 | ## 🧪 Compatibility Matrix 177 | 178 | Better Control has been tested on Arch Linux with Hyprland, GNOME, and KDE Plasma. It should work on most Linux distributions with minor adjustments. 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 |
CategoryCompatibility
Operating SystemLinux
DistributionsArch-based ✓ • Fedora-based ✓ • Debian-based ✓ • Void ✓ • Alpine ✓
Desktop EnvironmentsGNOME (tested) ✓ • KDE Plasma (tested) ✓ • XFCE • LXDE/LXQT
Window ManagersHyprland (tested) ✓ • Sway (tested) ✓ • i3 • Openbox • Fluxbox
Display ProtocolWayland (recommended) ✓ • X11 (partial functionality)
206 | 207 | > [!NOTE] 208 | > If you test Better Control on a different setup, please share your experience in the discussions or issues section. 209 | 210 | --- 211 | 212 | ### Made with ❤️ for the Linux community 213 | 214 | [Report Bug](https://github.com/better-ecosystem/better-control/issues) • 215 | [Request Feature](https://github.com/better-ecosystem/better-control/discussions) • 216 | [Contribute](https://github.com/better-ecosystem/better-control/tree/main?tab=readme-ov-file#--contribution) 217 | -------------------------------------------------------------------------------- /betterctl.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # A simple script to install or uninstall Better Control on your OS 3 | clear 4 | echo -e "\e[32mBetter Control Manager\e[0m" 5 | echo -e "your version : \e[34m6.12.0\e[0m" 6 | echo " " 7 | echo -e "This script is still under development to improve it if you find any errors head over to \e[31m\e[1mhttps://github.com/better-ecosystem/better-control/issues\e[0m and open an issue on it" 8 | echo " " 9 | 10 | set -e 11 | 12 | install_arch() { 13 | rm -rf ~/better-control-git 14 | git clone https://aur.archlinux.org/better-control-git.git 15 | clear 16 | cd better-control-git 17 | makepkg -si --noconfirm 18 | rm -rf ~/better-control-git 19 | clear 20 | echo -e "\e[1m\e[4m✅ Installation complete. You can run Better Control using the command 'control' or open the better-control app.\e[0m" 21 | } 22 | 23 | install_debian() { 24 | echo "⬇️Installing dependencies for Debian-based systems..." 25 | sudo apt update 26 | sudo apt install -y libgtk-3-dev network-manager bluez bluez-tools pulseaudio-utils brightnessctl python3-gi python3-dbus python3 power-profiles-daemon gammastep python3-requests python3-qrcode python3-setproctitle python3-pil usbguard 27 | 28 | clear 29 | git clone https://github.com/better-ecosystem/better-control.git 30 | cd better-control 31 | sudo make install 32 | rm -rf ~/better-control 33 | clear 34 | echo -e "\e[1m4m✅ Installation complete. You can run Better Control using the command 'control' or open the better-control app.\e[0m" 35 | } 36 | 37 | install_fedora() { 38 | echo "⬇️Installing dependencies for Fedora-based systems..." 39 | sudo dnf install -y gtk3 NetworkManager bluez pulseaudio-utils \ 40 | python3-gobject python3-dbus python3 power-profiles-daemon \ 41 | gammastep python3-requests python3-qrcode python3-setproctitle \ 42 | python3-pillow usbguard brightnessctl make --allowerasing 43 | clear 44 | 45 | git clone https://github.com/better-ecosystem/better-control.git 46 | cd better-control 47 | sudo make install 48 | rm -rf ~/better-control 49 | clear 50 | echo -e "\e[1m4m✅ Installation complete. You can run Better Control using the command 'control' or open the better-control app.\e[0m" 51 | } 52 | 53 | install_void() { 54 | echo "⬇️Installing dependencies for Void Linux..." 55 | sudo xbps-install -Sy NetworkManager pulseaudio-utils brightnessctl python3-gobject python3-dbus python3 power-profiles-daemon gammastep python3-requests python3-qrcode gtk+3 bluez python3-Pillow usbguard python3-pip python3-setproctitle 56 | clear 57 | 58 | git clone https://github.com/better-ecosystem/better-control.git 59 | cd better-control 60 | sudo make install 61 | rm -rf ~/better-control 62 | clear 63 | echo -e "\e[1m4m✅ Installation complete. You can run Better Control using the command 'control' or open the better-control app.\e[0m" 64 | } 65 | 66 | install_alpine() { 67 | echo "⬇️Installing dependencies for Alpine Linux..." 68 | sudo apk add gtk3 networkmanager bluez bluez-utils pulseaudio-utils brightnessctl py3-gobject py3-dbus python3 power-profiles-daemon gammastep py3-requests py3-qrcode py3-pip py3-setuptools gcc musl-dev python3-dev py3-pillow 69 | pip install setproctitle 70 | clear 71 | 72 | git clone https://github.com/better-ecosystem/better-control.git 73 | cd better-control 74 | sudo make install 75 | rm -rf ~/better-control 76 | clear 77 | echo -e "\e[1m4m✅ Installation complete. You can run Better Control using the command 'control' or open the better-control app.\e[0m" 78 | } 79 | 80 | uninstall_arch() { 81 | echo "Uninstalling better-control-git on Arch Linux..." 82 | sudo pacman -R --noconfirm better-control-git 83 | clear 84 | } 85 | 86 | uninstall_others() { 87 | echo "Uninstalling better-control on other distros..." 88 | git clone https://github.com/better-ecosystem/better-control 89 | cd better-control 90 | sudo make uninstall 91 | rm -rf ~/better-control 92 | clear 93 | echo "\e[1m4m✅ Uninstallation complete.\e[0m]" 94 | } 95 | 96 | detect_os() { 97 | if [ -f /etc/os-release ]; then 98 | . /etc/os-release 99 | echo "$ID" 100 | else 101 | echo "unknown" 102 | fi 103 | } 104 | 105 | confirm() { 106 | # Prompt for yes/no confirmation 107 | while true; do 108 | read -p "$1 [y/n]: " yn 109 | case $yn in 110 | [Yy]* ) return 0;; 111 | [Nn]* ) return 1;; 112 | * ) echo "Please answer yes or no. (Y for yes and N for no)";; 113 | esac 114 | done 115 | } 116 | 117 | echo -e "\e[1mDo you want to install or uninstall or update Better Control?\e0" 118 | echo -e "\e[32m 0) Install\e[0m" 119 | echo -e "\e[32m 1) Uninstall\e[0m" 120 | echo -e "\e[32m 2) Update\e[0m" 121 | echo -e "\e[3myour answer:\e0" 122 | read -r choice 123 | 124 | case "$choice" in 125 | 0|i|I|install) 126 | echo "Starting installation..." 127 | detect_os_id=$(detect_os) 128 | case "$detect_os_id" in 129 | arch|endeavouros|manjaro|garuda|cachyos) 130 | install_arch 131 | ;; 132 | debian|ubuntu|linuxmint|pop) 133 | install_debian 134 | ;; 135 | fedora|rhel) 136 | install_fedora 137 | ;; 138 | void) 139 | install_void 140 | ;; 141 | alpine) 142 | install_alpine 143 | ;; 144 | nixos) 145 | echo "❄️ Detected NixOS. This package has an unofficial flake here:" 146 | echo "https://github.com/Rishabh5321/better-control-flake" 147 | ;; 148 | *) 149 | echo "❌ Unsupported distro: $detect_os_id please open an issue on the GitHub repository on this and well add your distro." 150 | exit 1 151 | ;; 152 | esac 153 | ;; 154 | 1|u|U|uninstall) 155 | echo "You chose to uninstall Better Control." 156 | if confirm "Are you sure you want to uninstall? Y for yes , N for no"; then 157 | detect_os_id=$(detect_os) 158 | case "$detect_os_id" in 159 | arch|endeavouros|manjaro|garuda) 160 | uninstall_arch 161 | ;; 162 | *) 163 | uninstall_others 164 | ;; 165 | esac 166 | echo "✅ Uninstallation complete." 167 | else 168 | echo "❌ Uninstallation cancelled." 169 | fi 170 | ;; 171 | 172 | 2|update|Update) 173 | echo "Starting update (uninstall and reinstall)..." 174 | detect_os_id=$(detect_os) 175 | case "$detect_os_id" in 176 | arch|endeavouros|manjaro|garuda) 177 | echo "Uninstalling on Arch-based distro..." 178 | uninstall_arch 179 | echo "Installing on Arch-based distro..." 180 | install_arch 181 | ;; 182 | debian|ubuntu|linuxmint|pop) 183 | echo "Uninstalling on Debian-based distro..." 184 | uninstall_others 185 | echo "Installing on Debian-based distro..." 186 | install_debian 187 | ;; 188 | fedora|rhel) 189 | echo "Uninstalling on Fedora-based distro..." 190 | uninstall_others 191 | echo "Installing on Fedora-based distro..." 192 | install_fedora 193 | ;; 194 | void) 195 | echo "Uninstalling on Void Linux..." 196 | uninstall_others 197 | echo "Installing on Void Linux..." 198 | install_void 199 | ;; 200 | alpine) 201 | echo "Uninstalling on Alpine Linux..." 202 | uninstall_others 203 | echo "Installing on Alpine Linux..." 204 | install_alpine 205 | ;; 206 | nixos) 207 | echo "❄️ Detected NixOS. This package has an unofficial flake here:" 208 | echo "https://github.com/Rishabh5321/better-control-flake" 209 | ;; 210 | *) 211 | echo "❌ Unsupported distro: $detect_os_id please open an issue on the GitHub repository on this and well add your distro." 212 | exit 1 213 | ;; 214 | esac 215 | echo "✅ Update complete." 216 | ;; 217 | 218 | 219 | 220 | *) 221 | echo "Invalid choice. Please run the script again and choose 'install' or 'uninstall'." 222 | exit 1 223 | ;; 224 | esac 225 | -------------------------------------------------------------------------------- /src/better_control.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | from typing import Any 6 | import gi # type: ignore 7 | import sys 8 | from setproctitle import setproctitle 9 | import signal 10 | from utils.arg_parser import ArgParse 11 | from utils.logger import LogLevel, Logger 12 | from utils.settings import load_settings, ensure_config_dir, save_settings 13 | from utils.translations import get_translations 14 | 15 | # Initialize GTK before imports 16 | gi.require_version("Gtk", "3.0") 17 | gi.require_version("Gdk", "3.0") 18 | from gi.repository import Gtk, GLib # type: ignore 19 | 20 | from ui.main_window import BetterControl 21 | from utils.dependencies import check_all_dependencies 22 | from tools.bluetooth import restore_last_sink 23 | from ui.css.animations import load_animations_css 24 | 25 | 26 | def signal_handler(sig, frame): 27 | """Immediately kill the process on signal""" 28 | import os 29 | 30 | os._exit(0) 31 | 32 | 33 | def main(): 34 | # Register all critical signals 35 | signal.signal(signal.SIGINT, signal_handler) 36 | signal.signal(signal.SIGTERM, signal_handler) 37 | signal.signal(signal.SIGSEGV, signal_handler) 38 | signal.signal(signal.SIGABRT, signal_handler) 39 | 40 | # Initialize GTK safety net 41 | Gtk.init_check() 42 | if not Gtk.init_check()[0]: 43 | sys.stderr.write("Failed to initialize GTK\n") 44 | sys.exit(1) 45 | 46 | # Initialize environment 47 | os.environ['PYTHONUNBUFFERED'] = '1' 48 | os.environ['DBUS_FATAL_WARNINGS'] = '0' 49 | os.environ['GST_GL_XINITTHREADS'] = '1' 50 | os.environ['G_SLICE'] = 'always-malloc' 51 | os.environ['MALLOC_CHECK_'] = '2' 52 | os.environ['MALLOC_PERTURB_'] = '0' 53 | 54 | arg_parser = ArgParse(sys.argv) 55 | 56 | if arg_parser.find_arg(("-h", "--help")): 57 | arg_parser.print_help_msg(sys.stdout) 58 | sys.exit(0) 59 | 60 | logger = Logger(arg_parser) 61 | logger.log(LogLevel.Info, "Starting Better Control") 62 | 63 | setup_environment_and_dirs(logger) 64 | 65 | lang, txt = load_language_and_translations(arg_parser, logger) 66 | 67 | # Deferred loading of animations CSS until after main window is shown 68 | # load_animations_css() 69 | # logger.log(LogLevel.Info, "Loaded animations CSS") 70 | 71 | # Start dependency check asynchronously to avoid blocking startup 72 | import threading 73 | 74 | def check_dependencies_async(): 75 | try: 76 | if ( 77 | not arg_parser.find_arg(("-f", "--force")) 78 | and not check_all_dependencies(logger) 79 | ): 80 | logger.log( 81 | LogLevel.Error, 82 | "Missing required dependencies. Please install them and try again or use -f to force start.", 83 | ) 84 | # Optionally, show a GTK dialog warning here 85 | except Exception as e: 86 | logger.log(LogLevel.Error, f"Dependency check error: {e}") 87 | 88 | threading.Thread(target=check_dependencies_async, daemon=True).start() 89 | 90 | try: 91 | launch_application(arg_parser, logger, txt) 92 | except Exception as e: 93 | logger.log(LogLevel.Error, f"Fatal error starting application: {e}") 94 | import traceback 95 | 96 | traceback.print_exc() 97 | sys.exit(1) 98 | 99 | 100 | def setup_environment_and_dirs(logger): 101 | ensure_config_dir(logger) 102 | 103 | try: 104 | temp_dir = os.path.join("/tmp", "better-control") 105 | os.makedirs(temp_dir, exist_ok=True) 106 | logger.log(LogLevel.Info, f"Created temporary directory at {temp_dir}") 107 | except Exception as e: 108 | logger.log(LogLevel.Error, f"Error creating temporary directory: {e}") 109 | 110 | 111 | def load_language_and_translations(arg_parser, logger): 112 | settings = load_settings(logger) 113 | available_languages = ["en", "es", "pt", "fr", "id", "it", "tr", "de"] 114 | 115 | if arg_parser.find_arg(("-L", "--lang")): 116 | lang = arg_parser.option_arg(("-L", "--lang")) 117 | if lang not in available_languages: 118 | print(f"\033[1;31mError: Invalid language code '{lang}'\033[0m") 119 | print("Falling back to English (en)") 120 | print(f"Available languages: {', '.join(available_languages)}") 121 | logger.log( 122 | LogLevel.Warn, 123 | f"Invalid language code '{lang}'. Falling back to default(en)", 124 | ) 125 | lang = "en" 126 | settings["language"] = lang 127 | save_settings(settings, logger) 128 | logger.log(LogLevel.Info, f"Language set to: {lang}") 129 | else: 130 | lang = settings.get("language", "default") 131 | if lang not in (available_languages + ["default"]): 132 | lang = "en" 133 | settings["language"] = lang 134 | save_settings(settings, logger) 135 | logger.log( 136 | LogLevel.Warn, 137 | f"Invalid language '{lang}' in settings. Falling back to default(en)", 138 | ) 139 | logger.log(LogLevel.Info, f"Loaded language setting from settings: {lang}") 140 | # cast to Any to satisfy the argument type diagnostic 141 | txt = get_translations(logger, lang) # type: ignore 142 | return lang, txt 143 | 144 | 145 | def launch_application(arg_parser, logger, txt): 146 | import time 147 | 148 | time.sleep(0.1) 149 | 150 | logger.log(LogLevel.Info, "Creating main window") 151 | win = BetterControl(txt, arg_parser, logger) 152 | logger.log(LogLevel.Info, "Main window created successfully") 153 | 154 | setproctitle("better-control") 155 | 156 | def run_audio_operation(): 157 | restore_last_sink(logger) 158 | 159 | GLib.idle_add(run_audio_operation) 160 | 161 | option: Any = [] 162 | if arg_parser.find_arg(("-s", "--size")): 163 | optarg = arg_parser.option_arg(("-s", "--size")) 164 | if optarg is None or 'x' not in optarg: 165 | logger.log(LogLevel.Error, "Invalid window size") 166 | sys.exit(1) 167 | else: 168 | option = optarg.split('x') 169 | else: 170 | option = [900, 600] 171 | 172 | win.set_default_size(int(option[0]), int(option[1])) 173 | win.resize(int(option[0]), int(option[1])) 174 | win.connect("destroy", Gtk.main_quit) 175 | win.show_all() 176 | 177 | import threading 178 | 179 | def set_window_floating_rules(): 180 | xdg = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() 181 | sway_sock = os.environ.get("SWAYSOCK", "").lower() 182 | 183 | if "hyprland" in xdg: 184 | try: 185 | subprocess.run( 186 | [ 187 | "hyprctl", 188 | "keyword", 189 | "windowrule", 190 | "float,class:^(better_control.py)$", 191 | ], 192 | check=False, 193 | ) 194 | except Exception as e: 195 | logger.log(LogLevel.Warn, f"Failed to set hyprland window rule: {e}") 196 | elif "sway" in sway_sock: 197 | try: 198 | subprocess.run( 199 | [ 200 | "swaymsg", 201 | "for_window", 202 | '[app_id="^better_control.py$"]', 203 | "floating", 204 | "enable", 205 | ], 206 | check=False, 207 | ) 208 | except Exception as e: 209 | logger.log(LogLevel.Warn, f"Failed to set sway window rule: {e}") 210 | 211 | threading.Thread(target=set_window_floating_rules, daemon=True).start() 212 | 213 | # Load animations CSS asynchronously after window is shown 214 | def load_animations_async(): 215 | try: 216 | load_animations_css() 217 | logger.log(LogLevel.Info, "Loaded animations CSS asynchronously") 218 | except Exception as e: 219 | logger.log(LogLevel.Warn, f"Failed to load animations CSS asynchronously: {e}") 220 | 221 | threading.Thread(target=load_animations_async, daemon=True).start() 222 | 223 | try: 224 | Gtk.main() 225 | except KeyboardInterrupt: 226 | logger.log(LogLevel.Info, "Keyboard interrupt detected, exiting...") 227 | Gtk.main_quit() 228 | sys.exit(0) 229 | except Exception as e: 230 | logger.log(LogLevel.Error, f"Error in GTK main loop: {e}") 231 | sys.exit(1) 232 | 233 | 234 | def parse_arguments(): 235 | arg_parser = ArgParse(sys.argv) 236 | 237 | if arg_parser.find_arg(("-h", "--help")): 238 | arg_parser.print_help_msg(sys.stdout) 239 | sys.exit(0) 240 | 241 | return arg_parser 242 | 243 | 244 | def setup_logging(arg_parser): 245 | logger = Logger(arg_parser) 246 | logger.log(LogLevel.Info, "Starting Better Control") 247 | return logger 248 | 249 | 250 | def setup_temp_directory(logger): 251 | ensure_config_dir(logger) 252 | try: 253 | temp_dir = os.path.join("/tmp", "better-control") 254 | os.makedirs(temp_dir, exist_ok=True) 255 | logger.log(LogLevel.Info, f"Created temporary directory at {temp_dir}") 256 | except Exception as e: 257 | logger.log(LogLevel.Error, f"Error creating temporary directory: {e}") 258 | 259 | 260 | def process_language(arg_parser, logger): 261 | settings = load_settings(logger) 262 | available_languages = ["en", "es", "pt", "fr", "id", "it", "tr", "de"] 263 | 264 | if arg_parser.find_arg(("-L", "--lang")): 265 | lang = arg_parser.option_arg(("-L", "--lang")) 266 | if lang not in available_languages: 267 | print(f"\033[1;31mError: Invalid language code '{lang}'\033[0m") 268 | print("Falling back to English (en)") 269 | print(f"Available languages: {', '.join(available_languages)}") 270 | logger.log( 271 | LogLevel.Warn, 272 | f"Invalid language code '{lang}'. Falling back to default(en)", 273 | ) 274 | lang = "en" 275 | settings["language"] = lang 276 | save_settings(settings, logger) 277 | logger.log(LogLevel.Info, f"Language set to: {lang}") 278 | else: 279 | lang = settings.get("language", "default") 280 | if lang not in (available_languages + ["default"]): 281 | lang = "en" 282 | settings["language"] = lang 283 | save_settings(settings, logger) 284 | logger.log( 285 | LogLevel.Warn, 286 | f"Invalid language '{lang}' in settings. Falling back to default(en)", 287 | ) 288 | 289 | logger.log(LogLevel.Info, f"Loaded language setting from settings: {lang}") 290 | # cast to Any to satisfy the argument type diagnostic 291 | txt = get_translations(logger, lang) # type: ignore 292 | return txt 293 | 294 | 295 | def apply_environment_variables(): 296 | # Initialize environment variables 297 | os.environ['PYTHONUNBUFFERED'] = '1' 298 | os.environ['DBUS_FATAL_WARNINGS'] = '0' 299 | os.environ['GST_GL_XINITTHREADS'] = '1' 300 | os.environ['G_SLICE'] = 'always-malloc' 301 | os.environ['MALLOC_CHECK_'] = '2' 302 | os.environ['MALLOC_PERTURB_'] = '0' 303 | 304 | 305 | def launch_main_window(arg_parser, logger, txt): 306 | logger.log(LogLevel.Info, "Creating main window") 307 | win = BetterControl(txt, arg_parser, logger) 308 | logger.log(LogLevel.Info, "Main window created successfully") 309 | 310 | setproctitle("better-control") 311 | 312 | def run_audio_operation(): 313 | restore_last_sink(logger) 314 | 315 | GLib.idle_add(run_audio_operation) 316 | 317 | option: Any = [] 318 | if arg_parser.find_arg(("-s", "--size")): 319 | optarg = arg_parser.option_arg(("-s", "--size")) 320 | if optarg is None or 'x' not in optarg: 321 | logger.log(LogLevel.Error, "Invalid window size") 322 | sys.exit(1) 323 | else: 324 | option = optarg.split('x') 325 | else: 326 | option = [900, 600] 327 | 328 | win.set_default_size(int(option[0]), int(option[1])) 329 | win.resize(int(option[0]), int(option[1])) 330 | win.connect("destroy", Gtk.main_quit) 331 | win.show_all() 332 | 333 | xdg = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() 334 | sway_sock = os.environ.get("SWAYSOCK", "").lower() 335 | 336 | if "hyprland" in xdg: 337 | try: 338 | subprocess.run( 339 | [ 340 | "hyprctl", 341 | "keyword", 342 | "windowrule", 343 | "float,class:^(better_control.py)$", 344 | ], 345 | check=False, 346 | ) 347 | except Exception as e: 348 | logger.log( 349 | LogLevel.Warn, f"Failed to set hyprland window rule: {e}" 350 | ) 351 | elif "sway" in sway_sock: 352 | try: 353 | subprocess.run( 354 | [ 355 | "swaymsg", 356 | "for_window", 357 | '[app_id="^better_control.py$"]', 358 | "floating", 359 | "enable", 360 | ], 361 | check=False, 362 | ) 363 | except Exception as e: 364 | logger.log( 365 | LogLevel.Warn, f"Failed to set sway window rule: {e}" 366 | ) 367 | 368 | try: 369 | Gtk.main() 370 | except KeyboardInterrupt: 371 | logger.log(LogLevel.Info, "Keyboard interrupt detected, exiting...") 372 | Gtk.main_quit() 373 | sys.exit(0) 374 | except Exception as e: 375 | logger.log(LogLevel.Error, f"Error in GTK main loop: {e}") 376 | sys.exit(1) 377 | 378 | 379 | def initialize_and_start(): 380 | # Register signals again to be safe 381 | signal.signal(signal.SIGINT, signal_handler) 382 | signal.signal(signal.SIGTERM, signal_handler) 383 | 384 | apply_environment_variables() 385 | 386 | arg_parser = parse_arguments() 387 | 388 | logger = setup_logging(arg_parser) 389 | 390 | setup_temp_directory(logger) 391 | 392 | txt = process_language(arg_parser, logger) 393 | 394 | # Asynchronous dependency check to avoid blocking startup 395 | import threading 396 | 397 | def check_dependencies_async(): 398 | try: 399 | if ( 400 | not arg_parser.find_arg(("-f", "--force")) 401 | and not check_all_dependencies(logger) 402 | ): 403 | logger.log( 404 | LogLevel.Error, 405 | "Missing required dependencies. Please install them and try again or use -f to force start.", 406 | ) 407 | # Optionally, show a GTK dialog warning here 408 | except Exception as e: 409 | logger.log(LogLevel.Error, f"Dependency check error: {e}") 410 | 411 | threading.Thread(target=check_dependencies_async, daemon=True).start() 412 | 413 | try: 414 | launch_main_window(arg_parser, logger, txt) 415 | except Exception as e: 416 | logger.log(LogLevel.Error, f"Fatal error starting application: {e}") 417 | import traceback 418 | 419 | traceback.print_exc() 420 | sys.exit(1) 421 | 422 | 423 | if __name__ == "__main__": 424 | initialize_and_start() 425 | -------------------------------------------------------------------------------- /src/control.desktop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env xdg-open 2 | 3 | [Desktop Entry] 4 | Type=Application 5 | Name=Better Control 6 | Name[fr]=Meilleur Contrôle 7 | Name[es]=Mejor Control 8 | Name[pt]=Melhor Controlo 9 | Name[pt-BR]=Melhor Controle 10 | Name[id]=Kontrol Terbaik 11 | Comment=Fast & feature-rich control center 12 | StartupNotify=true 13 | Exec=/usr/bin/better-control 14 | Icon=settings 15 | Categories=System;Control;Settings; 16 | 17 | # A project by "quantumvoid._" and "nekrooo_" <--- discord id 18 | -------------------------------------------------------------------------------- /src/models/bluetooth_device.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import gi 3 | 4 | from utils.logger import LogLevel, Logger # type: ignore 5 | 6 | gi.require_version("Gtk", "3.0") 7 | from gi.repository import Gtk, Pango # type: ignore 8 | 9 | 10 | class BluetoothDeviceRow(Gtk.ListBoxRow): 11 | def __init__(self, device_info, logging: Logger): 12 | super().__init__() 13 | self.logging = logging 14 | self.set_margin_top(5) 15 | self.set_margin_bottom(5) 16 | self.set_margin_start(10) 17 | self.set_margin_end(10) 18 | 19 | # Parse device information 20 | parts = device_info.split(" ", 2) 21 | self.mac_address = parts[1] if len(parts) > 1 else "" 22 | self.device_name = parts[2] if len(parts) > 2 else self.mac_address 23 | 24 | # Get connection status 25 | self.is_connected = False 26 | try: 27 | status_output = subprocess.getoutput( 28 | f"bluetoothctl info {self.mac_address}" 29 | ) 30 | self.is_connected = "Connected: yes" in status_output 31 | 32 | # Get device type if available 33 | if "Icon: " in status_output: 34 | icon_line = [ 35 | line for line in status_output.split("\n") if "Icon: " in line 36 | ] 37 | self.device_type = ( 38 | icon_line[0].split("Icon: ")[1].strip() if icon_line else "unknown" 39 | ) 40 | else: 41 | self.device_type = "unknown" 42 | except Exception as e: 43 | self.logging.log(LogLevel.Error, f"Failed checking status for {self.mac_address}: {e}") 44 | self.device_type = "unknown" 45 | 46 | # Main container for the row 47 | container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) 48 | self.add(container) 49 | 50 | # Device icon based on type 51 | device_icon = Gtk.Image.new_from_icon_name( 52 | self.get_icon_name_for_device(), Gtk.IconSize.LARGE_TOOLBAR 53 | ) 54 | container.pack_start(device_icon, False, False, 0) 55 | 56 | # Left side with device name and type 57 | left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) 58 | 59 | name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 60 | name_label = Gtk.Label(label=self.device_name) 61 | name_label.set_halign(Gtk.Align.START) 62 | name_label.set_ellipsize(Pango.EllipsizeMode.END) 63 | name_label.set_max_width_chars(20) 64 | if self.is_connected: 65 | name_label.set_markup(f"{self.device_name}") 66 | name_box.pack_start(name_label, True, True, 0) 67 | 68 | if self.is_connected: 69 | connected_label = Gtk.Label(label=" (Connected)") 70 | connected_label.get_style_context().add_class("success-label") 71 | name_box.pack_start(connected_label, False, False, 0) 72 | 73 | left_box.pack_start(name_box, False, False, 0) 74 | 75 | # Device details box 76 | details_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 77 | 78 | type_label = Gtk.Label(label=self.get_friendly_device_type()) 79 | type_label.set_halign(Gtk.Align.START) 80 | type_label.get_style_context().add_class("dim-label") 81 | details_box.pack_start(type_label, False, False, 0) 82 | 83 | mac_label = Gtk.Label(label=self.mac_address) 84 | mac_label.set_halign(Gtk.Align.START) 85 | mac_label.get_style_context().add_class("dim-label") 86 | details_box.pack_start(mac_label, False, False, 10) 87 | 88 | left_box.pack_start(details_box, False, False, 0) 89 | 90 | container.pack_start(left_box, True, True, 0) 91 | 92 | def get_icon_name_for_device(self): 93 | """Return appropriate icon based on device type""" 94 | if ( 95 | self.device_type == "audio-headset" 96 | or self.device_type == "audio-headphones" 97 | ): 98 | return "audio-headset-symbolic" 99 | elif self.device_type == "audio-card": 100 | return "audio-speakers-symbolic" 101 | elif self.device_type == "input-keyboard": 102 | return "input-keyboard-symbolic" 103 | elif self.device_type == "input-mouse": 104 | return "input-mouse-symbolic" 105 | elif self.device_type == "input-gaming": 106 | return "input-gaming-symbolic" 107 | elif self.device_type == "phone": 108 | return "phone-symbolic" 109 | else: 110 | return "bluetooth-symbolic" 111 | 112 | def get_friendly_device_type(self): 113 | """Return user-friendly device type name""" 114 | type_names = { 115 | "audio-headset": "Headset", 116 | "audio-headphones": "Headphones", 117 | "audio-card": "Speaker", 118 | "input-keyboard": "Keyboard", 119 | "input-mouse": "Mouse", 120 | "input-gaming": "Game Controller", 121 | "phone": "Phone", 122 | "unknown": "Device", 123 | } 124 | return type_names.get(self.device_type, "Bluetooth Device") 125 | 126 | def get_mac_address(self): 127 | return self.mac_address 128 | 129 | def get_device_name(self): 130 | return self.device_name 131 | 132 | def get_is_connected(self): 133 | return self.is_connected 134 | -------------------------------------------------------------------------------- /src/models/wifi_network.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import gi 3 | 4 | from utils.logger import LogLevel, Logger # type: ignore 5 | 6 | gi.require_version("Gtk", "3.0") 7 | from gi.repository import Gtk # type: ignore 8 | 9 | 10 | class WiFiNetworkRow(Gtk.ListBoxRow): 11 | def __init__(self, network_info, logging: Logger): 12 | super().__init__() 13 | self.logging = logging 14 | self.set_margin_top(5) 15 | self.set_margin_bottom(5) 16 | self.set_margin_start(10) 17 | self.set_margin_end(10) 18 | 19 | parts = network_info.split() 20 | self.is_connected = "*" in parts[0] 21 | 22 | self.ssid = self._extract_ssid(parts, network_info) 23 | self.security = self._extract_security(network_info) 24 | signal_value = self._extract_signal_strength(parts) 25 | 26 | if signal_value >= 80: 27 | icon_name = "network-wireless-signal-excellent-symbolic" 28 | elif signal_value >= 60: 29 | icon_name = "network-wireless-signal-good-symbolic" 30 | elif signal_value >= 40: 31 | icon_name = "network-wireless-signal-ok-symbolic" 32 | elif signal_value > 0: 33 | icon_name = "network-wireless-signal-weak-symbolic" 34 | else: 35 | icon_name = "network-wireless-signal-none-symbolic" 36 | 37 | if self.security != "Open": 38 | security_icon = "network-wireless-encrypted-symbolic" 39 | else: 40 | security_icon = "network-wireless-symbolic" 41 | 42 | container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) 43 | self.add(container) 44 | 45 | wifi_icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.LARGE_TOOLBAR) 46 | container.pack_start(wifi_icon, False, False, 0) 47 | 48 | left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) 49 | 50 | ssid_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 51 | ssid_label = Gtk.Label(label=self.ssid) 52 | ssid_label.set_halign(Gtk.Align.START) 53 | if self.is_connected: 54 | ssid_label.set_markup(f"{self.ssid}") 55 | ssid_box.pack_start(ssid_label, True, True, 0) 56 | 57 | if self.is_connected: 58 | connected_label = Gtk.Label(label=" (Connected)") 59 | connected_label.get_style_context().add_class("success-label") 60 | ssid_box.pack_start(connected_label, False, False, 0) 61 | 62 | left_box.pack_start(ssid_box, False, False, 0) 63 | 64 | details_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 65 | 66 | security_image = Gtk.Image.new_from_icon_name( 67 | security_icon, Gtk.IconSize.SMALL_TOOLBAR 68 | ) 69 | details_box.pack_start(security_image, False, False, 0) 70 | 71 | security_label = Gtk.Label(label=self.security) 72 | security_label.set_halign(Gtk.Align.START) 73 | security_label.get_style_context().add_class("dim-label") 74 | details_box.pack_start(security_label, False, False, 0) 75 | 76 | left_box.pack_start(details_box, False, False, 0) 77 | 78 | container.pack_start(left_box, True, True, 0) 79 | 80 | signal_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) 81 | signal_box.set_halign(Gtk.Align.END) 82 | 83 | signal_label = Gtk.Label(label=self.signal_strength) 84 | signal_box.pack_start(signal_label, False, False, 0) 85 | 86 | container.pack_end(signal_box, False, False, 0) 87 | 88 | self.original_network_info = network_info 89 | 90 | def _extract_ssid(self, parts, network_info): 91 | if len(parts) > 1: 92 | if self.is_connected: 93 | try: 94 | active_connections = subprocess.getoutput( 95 | "nmcli -t -f NAME,DEVICE connection show --active" 96 | ).split("\n") 97 | for conn in active_connections: 98 | if ":" in conn: 99 | conn_name = conn.split(":")[0] 100 | conn_type = subprocess.getoutput( 101 | f"nmcli -t -f TYPE connection show '{conn_name}'" 102 | ) 103 | if "wifi" in conn_type: 104 | return conn_name 105 | else: 106 | return parts[1] 107 | except Exception as e: 108 | self.logging.log( 109 | LogLevel.Error, f"Failed getting active connection name: {e}" 110 | ) 111 | return parts[1] 112 | else: 113 | return parts[1] 114 | else: 115 | return "Unknown" 116 | 117 | def _extract_security(self, network_info): 118 | if "WPA2" in network_info: 119 | return "WPA2" 120 | elif "WPA3" in network_info: 121 | return "WPA3" 122 | elif "WPA" in network_info: 123 | return "WPA" 124 | elif "WEP" in network_info: 125 | return "WEP" 126 | else: 127 | return "Open" 128 | 129 | def _extract_signal_strength(self, parts): 130 | signal_value = 0 131 | try: 132 | if len(parts) > 6 and parts[6].isdigit(): 133 | signal_value = int(parts[6]) 134 | self.signal_strength = f"{signal_value}%" 135 | else: 136 | for i, p in enumerate(parts): 137 | if p.isdigit() and 0 <= int(p) <= 100: 138 | if i != 4: 139 | signal_value = int(p) 140 | self.signal_strength = f"{signal_value}%" 141 | break 142 | else: 143 | self.signal_strength = "0%" 144 | except (IndexError, ValueError) as e: 145 | self.logging.log( 146 | LogLevel.Error, f"Failed parsing signal strength from {parts}: {e}" 147 | ) 148 | self.signal_strength = "0%" 149 | signal_value = 0 150 | return signal_value 151 | 152 | def get_ssid(self): 153 | return self.ssid 154 | 155 | def get_security(self): 156 | return self.security 157 | 158 | def get_original_network_info(self): 159 | return self.original_network_info 160 | 161 | def is_secured(self): 162 | return self.security != "Open" 163 | -------------------------------------------------------------------------------- /src/tools/display.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | from typing import Dict, List 5 | 6 | from utils.logger import LogLevel, Logger 7 | from tools.globals import get_current_session 8 | from tools.hyprland import get_hyprland_displays, set_hyprland_transform 9 | 10 | def get_brightness(logging: Logger) -> int: 11 | """Get the current brightness level 12 | 13 | Returns: 14 | int: current brightness percentage 15 | """ 16 | try: 17 | output = subprocess.getoutput("brightnessctl g") 18 | max_brightness = int(subprocess.getoutput("brightnessctl m")) 19 | current_brightness = int(output) 20 | return int((current_brightness / max_brightness) * 100) 21 | except Exception as e: 22 | logging.log(LogLevel.Error, f"Failed getting brightness: {e}") 23 | return 0 24 | 25 | 26 | def set_brightness(value: int, logging: Logger) -> None: 27 | """Set the brightness level 28 | 29 | Args: 30 | value (int): brightness percentage to set 31 | """ 32 | try: 33 | subprocess.run(["brightnessctl", "s", f"{value}%"], check=True, stdout=subprocess.DEVNULL) 34 | except subprocess.CalledProcessError as e: 35 | logging.log(LogLevel.Error, f"Failed setting brightness: {e}") 36 | 37 | 38 | def get_displays(logging: Logger) -> List[str]: 39 | """Get list of connected displays 40 | 41 | Returns: 42 | List[str]: List of display names 43 | """ 44 | try: 45 | output = subprocess.getoutput("xrandr --query") 46 | displays = [] 47 | for line in output.split("\n"): 48 | if " connected" in line: 49 | displays.append(line.split()[0]) 50 | return displays 51 | except Exception as e: 52 | logging.log(LogLevel.Error, f"Failed getting displays: {e}") 53 | return [] 54 | 55 | 56 | def get_display_info(display: str, logging: Logger) -> Dict[str, str]: 57 | """Get information about a specific display 58 | 59 | Args: 60 | display (str): Display name 61 | 62 | Returns: 63 | Dict[str, str]: Dictionary containing display information 64 | """ 65 | try: 66 | session = get_current_session() 67 | if session == "Hyprland": 68 | displays = get_hyprland_displays() 69 | transform_map = { 70 | 0: "normal", 71 | 1: "right", 72 | 2: "inverted", 73 | 3: "left" 74 | } 75 | if display in displays: 76 | return {"rotation": transform_map.get(displays[display], "normal")} 77 | 78 | output = subprocess.run(f"xrandr --query --verbose | grep -A10 {display}", capture_output=True,text=True) 79 | lines = output.stdout.split("\n") 80 | 81 | for i, line in enumerate(lines): 82 | if display in line and " connected" in line: 83 | parts = line.split() 84 | for idx, part in enumerate(parts): 85 | if part.startswith("(") and part.endswith(")"): 86 | orientation = parts[idx + 1] 87 | return {"rotation": orientation} 88 | 89 | # Return default if no rotation information found 90 | return {"rotation": "normal"} 91 | except Exception as e: 92 | logging.log(LogLevel.Error, f"Failed getting display info: {e}") 93 | return {"rotation": "normal"} 94 | 95 | def rotate_display(display: str, desktop_env: str, orientation: str, logging: Logger) -> bool: 96 | """Change the orientation of the display 97 | Args: 98 | display: Display name 99 | orientation: Orientation ('normal', 'left', 'right', 'inverted') 100 | desktop_env: Desktop environment ('hyprland', 'sway', 'gnome') 101 | logging: Logger 102 | 103 | Returns: 104 | bool: True if successful, False otherwise 105 | """ 106 | try: 107 | session = get_current_session() 108 | 109 | if session == "Hyprland": 110 | return set_hyprland_transform(logging, display, orientation) 111 | 112 | elif desktop_env.lower() == "gnome": 113 | rotation_map = { 114 | "normal": "normal", 115 | "left": "left", 116 | "right": "right", 117 | "inverted": "inverted" 118 | } 119 | rotation = rotation_map.get(orientation.lower(), "normal") 120 | # Use gsettings to rotate display in gnome 121 | cmd = [ 122 | "gsettings", 123 | "set", 124 | "org.gnome.settings-daemon.plugins.orientation", 125 | "active", 126 | "true" 127 | ] 128 | subprocess.run(cmd, check=True) 129 | 130 | # Apply the rotation to the specific monitor 131 | cmd = [ 132 | "xrandr", 133 | "--output", 134 | display, 135 | "--rotate", 136 | rotation 137 | ] 138 | subprocess.run(cmd, check=True) 139 | return True 140 | except Exception as err: 141 | logging.log(LogLevel.Error, f"Failed to change display orientation for {display}: {err}") 142 | return False 143 | 144 | -------------------------------------------------------------------------------- /src/tools/globals.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import subprocess 4 | import gi 5 | 6 | from tools.bluetooth import get_bluetooth_manager 7 | from tools.wifi import wifi_supported 8 | from utils.logger import LogLevel 9 | gi.require_version("Gtk", "3.0") 10 | from gi.repository import Gtk, Gdk # type: ignore 11 | 12 | def get_current_session(): 13 | if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower(): 14 | return "Hyprland" 15 | elif "sway" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower(): 16 | return "sway" 17 | else: 18 | return False 19 | 20 | 21 | # css for wifi_tab 22 | def get_wifi_css(): 23 | css_provider = Gtk.CssProvider() 24 | css_provider.load_from_data(b""" 25 | .qr-button{ 26 | background-color: transparent; 27 | } 28 | .qr_image_holder{ 29 | border-radius: 12px; 30 | } 31 | .scan_label{ 32 | font-size: 18px; 33 | font-weight: bold; 34 | } 35 | .ssid-box{ 36 | background: @wm_button_unfocused_bg; 37 | border-radius: 6px; 38 | border-bottom-right-radius: 0px; 39 | border-bottom-left-radius: 0px; 40 | padding: 10px; 41 | } 42 | .dimmed-label{ 43 | opacity: 0.5; 44 | } 45 | .secrity-box{ 46 | background: @wm_button_unfocused_bg; 47 | border-radius: 6px; 48 | border-top-right-radius: 0px; 49 | border-top-left-radius: 0px; 50 | padding: 10px; 51 | } 52 | """) 53 | 54 | Gtk.StyleContext.add_provider_for_screen( 55 | Gdk.Screen.get_default(), 56 | css_provider, 57 | Gtk.STYLE_PROVIDER_PRIORITY_USER 58 | ) 59 | 60 | # check for battery suppoert 61 | def battery_supported() -> bool: 62 | try: 63 | result = subprocess.run( 64 | "upower -e", shell=True, capture_output=True, text=True 65 | ) 66 | if result.returncode == 0 and "/battery_" in result.stdout: 67 | return True 68 | return False 69 | except Exception: 70 | return False 71 | 72 | def check_hardware_support(self, visibility, logging): 73 | """Check if wifi, bluetooth, battery is supported or not""" 74 | 75 | bluetooth_manager = get_bluetooth_manager(logging) 76 | hardware_checks = { 77 | "Wi-Fi": { 78 | "check": wifi_supported, 79 | "log_message": "No Wi-Fi adapter found, skipping Wi-Fi tab", 80 | }, 81 | "Battery": { 82 | "check": battery_supported, 83 | "log_message": "No battery found, skipping Battery tab", 84 | }, 85 | "Bluetooth": { 86 | "check": bluetooth_manager.bluetooth_supported, 87 | "log_message": "No Bluetooth adapter found, skipping Bluetooth tab", 88 | }, 89 | } 90 | for tab_name, check_info in hardware_checks.items(): 91 | try: 92 | if not check_info["check"](): 93 | logging.log(LogLevel.Warn, check_info["log_message"]) 94 | visibility[tab_name] = False 95 | except Exception as e: 96 | logging.log(LogLevel.Error, f"Error checking {tab_name} support: {e}") -------------------------------------------------------------------------------- /src/tools/hyprland.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import subprocess 5 | 6 | from utils.logger import LogLevel, Logger 7 | 8 | CONFIG_FILES = [ 9 | Path.home() / ".config/hypr/hyprland.conf", 10 | Path.home() / ".config/hypr/autostart.conf" 11 | ] 12 | 13 | def get_hyprland_startup_apps(): 14 | """Retrieve apps started using exec-once in Hyprland""" 15 | startup_apps = {} 16 | for hypr_config in CONFIG_FILES: 17 | if hypr_config.exists(): 18 | with open(hypr_config, "r") as f: 19 | lines = f.readlines() 20 | 21 | for i, line in enumerate(lines): 22 | stripped = line.strip() 23 | if "exec-once" in stripped: 24 | enabled = not stripped.startswith("#") 25 | 26 | cleared_line = stripped.lstrip("#").strip() 27 | if "=" in cleared_line: 28 | parts = cleared_line.split("=", 1) 29 | command = parts[1].strip().strip('"') 30 | else: 31 | parts = cleared_line.split(None, 1) 32 | if len(parts) > 1: 33 | command = parts[1].strip().strip('"') 34 | else: 35 | continue 36 | startup_apps[command] = { 37 | "name": command, 38 | "type": "hyprland", 39 | "path": hypr_config, 40 | "line_index": i, 41 | "enabled": enabled 42 | } 43 | return startup_apps 44 | 45 | def toggle_hyprland_startup(command): 46 | """Toggle the startup state of command in Hyprland config""" 47 | apps = get_hyprland_startup_apps() 48 | if command not in apps: 49 | print(f"Command '{command}' not found in Hyprland autostart apps.") 50 | return 51 | 52 | app_info = apps[command] 53 | config_path = app_info["path"] 54 | 55 | with open(config_path, "r") as f: 56 | lines = f.readlines() 57 | 58 | if app_info["enabled"]: 59 | # Comment out the line to disable the app 60 | lines[app_info["line_index"]] = f"# {lines[app_info['line_index']].lstrip('# ').strip()}\n" 61 | print(f"Disabled startup for: {command}") 62 | else: 63 | # Uncomment the line to enable the app 64 | lines[app_info["line_index"]] = lines[app_info["line_index"]].lstrip("# ").strip() + "\n" 65 | print(f"Enabled startup for: {command}") 66 | 67 | with open(config_path, "w") as f: 68 | f.writelines(lines) 69 | 70 | # Reload hyprland 71 | subprocess.run(["hyprctl", "reload"]) 72 | 73 | def get_hyprland_displays() -> dict: 74 | """Get current displays and their transforms from hyprctl monitors 75 | Returns: 76 | dict: Dictionary with display names as keys and transforms as values for each display 77 | """ 78 | try: 79 | result = subprocess.run(["hyprctl", "monitors"], capture_output=True, text=True) 80 | if result.returncode != 0: 81 | return {} 82 | 83 | displays = {} 84 | current_display = None 85 | 86 | for line in result.stdout.split('\n'): 87 | line = line.strip() 88 | if line.startswith('Monitor'): 89 | current_display = line.split()[1] 90 | displays[current_display] = {} 91 | 92 | elif current_display: 93 | if 'transform:' in line: 94 | transform = int(line.split(':')[1].strip()) 95 | displays[current_display]['transform'] = transform 96 | elif 'scale:' in line: 97 | scale = float(line.split(':')[1].strip()) 98 | displays[current_display]['scale'] = scale 99 | elif '@' in line and 'x' in line and 'at ' in line: 100 | res_part = line.split('@')[0].strip() 101 | width, height = map(int, res_part.split('x')) 102 | displays[current_display]['resolution'] = {'width': width, 'height': height} 103 | 104 | # Get refresh rate 105 | refresh_part = line.split('@')[1].split(' at ')[0].strip() 106 | refresh = int(float(refresh_part)) 107 | displays[current_display]['refresh_rate'] = refresh 108 | 109 | if ' at ' in line: 110 | pos_part = line.split(' at ')[1].strip() 111 | pos_x, pos_y = map(int, pos_part.split('x')) 112 | displays[current_display]['position'] = {'x': pos_x, 'y': pos_y} 113 | 114 | return displays 115 | except Exception as e: 116 | print(f"Error getting Hyprland displays: {e}") 117 | return {} 118 | 119 | def set_hyprland_transform(logging: Logger, display: str, orientation: str) -> bool: 120 | """Set display transform in Hyprland 121 | 122 | Args: 123 | display: Display name (e.g. 'eDP-1') 124 | orientation: Desired orientation ('normal: 0', 'right:1 ', 'inverted: 2', 'left: 3') 125 | Returns: 126 | bool: True if successful, False otherwise 127 | """ 128 | try: 129 | displays = get_hyprland_displays() 130 | if display not in displays: 131 | logging.log(LogLevel.Error, f"Display '{display}' not found") 132 | return False 133 | 134 | info = displays[display] 135 | 136 | resolution = info['resolution'] 137 | refresh = info['refresh_rate'] 138 | scale = info.get('scale', 1.0) 139 | if 'position' not in info: 140 | position = {'x': 0, 'y': 0} # Default position 141 | logging.log(LogLevel.Warn, f"Position information missing for display '{display}', using (0,0)") 142 | else: 143 | position = info['position'] 144 | current_transform = info.get('transform', 0) 145 | transform_map = { 146 | "normal": 0, 147 | "90°": 1, 148 | "180°": 2, 149 | "270°": 3, 150 | "flip": 4, 151 | "flip-vertical": 5, 152 | "flip-90°": 6, 153 | "flip-270°": 7, 154 | "rotate-ccw": (current_transform + 1) % 4, 155 | "rotate-cw": (current_transform - 1) % 4, 156 | "flip-ccw": ((current_transform + 1) % 4) + 4 if current_transform >= 4 else current_transform, 157 | "flip-cw": ((current_transform - 1) % 4) + 4 if current_transform >= 4 else current_transform 158 | } 159 | 160 | if orientation.lower() in ["rotate-cw", "rotate-ccw", "flip-cw", "flip-ccw"]: 161 | transform = transform_map[orientation.lower()] 162 | else: 163 | transform = transform_map.get(orientation.lower(), 0) 164 | 165 | pos_str = f"{position['x']}x{position['y']}" 166 | # hyprctl command to transform display 167 | cmd = [ 168 | "hyprctl", 169 | "keyword", 170 | f"monitor {display},{resolution['width']}x{resolution['height']}@{refresh}," 171 | f"{pos_str},{scale},transform,{transform}" 172 | ] 173 | 174 | logging.log(LogLevel.Info, f"Running command: {' '.join(cmd)}") 175 | result = subprocess.run(cmd, check=True) 176 | 177 | if result.returncode != 0: 178 | logging.log(LogLevel.Error, f"Command failed with: {result.stderr}") 179 | return False 180 | return True 181 | 182 | except Exception as e: 183 | print(f"Error setting Hyprland transform: {e}") 184 | return False 185 | 186 | 187 | def get_hyprland_rotation(): 188 | try: 189 | result = subprocess.run(["hyprctl", "monitors"], capture_output=True, text=True) 190 | output = result.stdout 191 | 192 | for line in output.splitlines(): 193 | if "transform:" in line: 194 | transform_value = int(line.strip().split(":")[1].strip()) 195 | transform_map = { 196 | 0: "normal", 197 | 1: "90°", 198 | 2: "180°", 199 | 3: "270°", 200 | 4: "flip", 201 | 5: "flip-vertical", 202 | 6: "flip-90°", 203 | 7: "flip-270°" 204 | } 205 | return transform_map.get(transform_value, f"unknown ({transform_value})") 206 | except Exception as e: 207 | return f"Error: {e}" -------------------------------------------------------------------------------- /src/tools/network.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | from typing import Tuple, List 5 | 6 | from utils.logger import LogLevel, Logger 7 | 8 | 9 | def get_network_speed(logging: Logger) -> Tuple[float, float]: 10 | """Measure current network speed 11 | 12 | Returns: 13 | Tuple[float, float]: Upload and download speeds in Mbps 14 | """ 15 | try: 16 | # Get network interfaces for wifi and ethernet 17 | interfaces_output = subprocess.getoutput( 18 | "nmcli -t -f DEVICE,TYPE device | grep -E 'wifi|ethernet'" 19 | ).split("\n") 20 | logging.log(LogLevel.Debug, f"Interfaces output: {interfaces_output}") 21 | wifi_interfaces = [line.split(":")[0] for line in interfaces_output if "wifi" in line and ":" in line] 22 | ethernet_interfaces = [line.split(":")[0] for line in interfaces_output if "ethernet" in line and ":" in line] 23 | logging.log(LogLevel.Debug, f"WiFi interfaces: {wifi_interfaces}") 24 | logging.log(LogLevel.Debug, f"Ethernet interfaces: {ethernet_interfaces}") 25 | 26 | if wifi_interfaces: 27 | interface = wifi_interfaces[0] 28 | elif ethernet_interfaces: 29 | interface = ethernet_interfaces[0] 30 | else: 31 | logging.log(LogLevel.Debug, "No wifi or ethernet interfaces found") 32 | return 0.0, 0.0 33 | 34 | logging.log(LogLevel.Debug, f"Using interface: {interface}") 35 | 36 | operstate = subprocess.getoutput(f"cat /sys/class/net/{interface}/operstate").strip() 37 | logging.log(LogLevel.Debug, f"Interface {interface} operstate: {operstate}") 38 | if operstate != "up": 39 | logging.log(LogLevel.Debug, f"Interface {interface} is not up") 40 | return 0.0, 0.0 41 | 42 | rx_bytes = int( 43 | subprocess.getoutput(f"cat /sys/class/net/{interface}/statistics/rx_bytes") 44 | ) 45 | tx_bytes = int( 46 | subprocess.getoutput(f"cat /sys/class/net/{interface}/statistics/tx_bytes") 47 | ) 48 | logging.log(LogLevel.Debug, f"rx_bytes: {rx_bytes}, tx_bytes: {tx_bytes}") 49 | 50 | # Store current values 51 | if not hasattr(get_network_speed, "prev_rx_bytes"): 52 | get_network_speed.prev_rx_bytes = rx_bytes # type: ignore 53 | get_network_speed.prev_tx_bytes = tx_bytes # type: ignore 54 | return 0.0, 0.0 55 | 56 | # Calculate speed 57 | rx_speed = rx_bytes - get_network_speed.prev_rx_bytes # type: ignore 58 | tx_speed = tx_bytes - get_network_speed.prev_tx_bytes # type: ignore 59 | 60 | # Update previous values 61 | get_network_speed.prev_rx_bytes = rx_bytes # type: ignore 62 | get_network_speed.prev_tx_bytes = tx_bytes # type: ignore 63 | 64 | # Convert to Mbps 65 | rx_speed_mbps = (rx_speed * 8) / (1024 * 1024) # Convert to Mbps 66 | tx_speed_mbps = (tx_speed * 8) / (1024 * 1024) # Convert to Mbps 67 | 68 | return tx_speed_mbps, rx_speed_mbps 69 | 70 | except Exception as e: 71 | logging.log(LogLevel.Error, f"Failed measuring network speed: {e}") 72 | return 0.0, 0.0 73 | 74 | 75 | def get_wifi_networks(logging: Logger) -> List[str]: 76 | """Get list of available WiFi networks 77 | 78 | Returns: 79 | List[str]: List of network information strings 80 | """ 81 | try: 82 | # Use fields parameter to get a more consistent format, including SIGNAL explicitly 83 | output = subprocess.getoutput( 84 | "nmcli -f IN-USE,BSSID,SSID,MODE,CHAN,RATE,SIGNAL,BARS,SECURITY dev wifi list" 85 | ) 86 | networks = output.split("\n")[1:] # Skip header row 87 | return networks 88 | except Exception as e: 89 | logging.log(LogLevel.Error, f"Failed getting WiFi networks: {e}") 90 | return [] 91 | 92 | 93 | def get_wifi_status(logging: Logger) -> bool: 94 | """Check if WiFi is enabled 95 | 96 | Returns: 97 | bool: True if WiFi is enabled, False otherwise 98 | """ 99 | try: 100 | wifi_status = subprocess.getoutput("nmcli radio wifi").strip() 101 | return wifi_status.lower() == "enabled" 102 | except Exception as e: 103 | logging.log(LogLevel.Error, f"Failed getting WiFi status: {e}") 104 | return False 105 | 106 | 107 | def set_wifi_status(enabled: bool, logging: Logger) -> bool: 108 | """Enable or disable WiFi 109 | 110 | Args: 111 | enabled (bool): True to enable WiFi, False to disable 112 | 113 | Returns: 114 | bool: True if operation was successful, False otherwise 115 | """ 116 | try: 117 | command = "on" if enabled else "off" 118 | subprocess.run(["nmcli", "radio", "wifi", command], check=True) 119 | return True 120 | except subprocess.CalledProcessError as e: 121 | logging.log( 122 | LogLevel.Error, f"Failed to {'enable' if enabled else 'disable'} WiFi: {e}" 123 | ) 124 | return False 125 | 126 | 127 | def connect_to_wifi( 128 | ssid: str, logging: Logger, password: str = "", remember: bool = True 129 | ) -> bool: 130 | """Connect to a WiFi network 131 | 132 | Args: 133 | ssid (str): Network SSID 134 | password (str, optional): Network password. Defaults to None. 135 | remember (bool, optional): Whether to remember the network. Defaults to True. 136 | 137 | Returns: 138 | bool: True if connection was successful, False otherwise 139 | """ 140 | try: 141 | if password: 142 | # Create connection profile 143 | add_command = [ 144 | "nmcli", 145 | "con", 146 | "add", 147 | "type", 148 | "wifi", 149 | "con-name", 150 | "ssid", 151 | "wifi-sec.key-mgmt", 152 | "wpa-psk", 153 | "wifi-sec.psk", 154 | password, 155 | ] 156 | 157 | if not remember: 158 | add_command.extend(["connection.autoconnect", "no"]) 159 | 160 | subprocess.run(add_command, check=True) 161 | else: 162 | # For open networks 163 | add_command = [ 164 | "nmcli", 165 | "con", 166 | "add", 167 | "type", 168 | "wifi", 169 | "con-name", 170 | "ssid", 171 | ] 172 | subprocess.run(add_command, check=True) 173 | 174 | # Activate the connection 175 | subprocess.run(["nmcli", "con", "up", "ssid"], check=True) 176 | return True 177 | 178 | except subprocess.CalledProcessError as e: 179 | logging.log(LogLevel.Error, f"Failed to connect to network {ssid}: {e}") 180 | return False 181 | 182 | 183 | def disconnect_wifi(logging: Logger) -> bool: 184 | """Disconnect from current WiFi network 185 | 186 | Returns: 187 | bool: True if disconnection was successful, False otherwise 188 | """ 189 | try: 190 | # First approach: Try to find WiFi device that's connected 191 | connected_wifi_device = subprocess.getoutput( 192 | "nmcli -t -f DEVICE,STATE dev | grep wifi.*:connected" 193 | ) 194 | 195 | if connected_wifi_device: 196 | # Extract device name 197 | wifi_device = connected_wifi_device.split(":")[0] 198 | 199 | # Get connection name for this device 200 | device_connection = subprocess.getoutput( 201 | f"nmcli -t -f NAME,DEVICE con show --active | grep {wifi_device}" 202 | ) 203 | 204 | if device_connection and ":" in device_connection: 205 | connection_name = device_connection.split(":")[0] 206 | subprocess.run(["nmcli", "con", "down", connection_name], check=True) 207 | return True 208 | 209 | # Second approach: Try checking all active WiFi connections 210 | active_connections = subprocess.getoutput( 211 | "nmcli -t -f NAME,TYPE con show --active" 212 | ).split("\n") 213 | for conn in active_connections: 214 | if ":" in conn and ( 215 | "wifi" in conn.lower() or "802-11-wireless" in conn.lower() 216 | ): 217 | connection_name = conn.split(":")[0] 218 | subprocess.run(["nmcli", "con", "down", connection_name], check=True) 219 | return True 220 | 221 | return False 222 | 223 | except subprocess.CalledProcessError as e: 224 | logging.log(LogLevel.Error, f"Failed to disconnect: {e}") 225 | return False 226 | 227 | 228 | def forget_wifi_network(ssid: str, logging: Logger) -> bool: 229 | """Remove a saved WiFi network 230 | 231 | Args: 232 | ssid (str): Network SSID to forget 233 | 234 | Returns: 235 | bool: True if operation was successful, False otherwise 236 | """ 237 | try: 238 | subprocess.run(["nmcli", "connection", "delete", ssid], check=True) 239 | return True 240 | except subprocess.CalledProcessError as e: 241 | logging.log(LogLevel.Error, f"Failed to forget network: {e}") 242 | return False 243 | -------------------------------------------------------------------------------- /src/tools/notify.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from utils.logger import Logger, LogLevel 3 | 4 | def notify_send( 5 | logging:Logger, 6 | app_name="", 7 | urgency="normal", 8 | app_icon="settings", 9 | icon="", 10 | summary="", 11 | body="", 12 | actions_array=None 13 | ): 14 | """ Send notification using notify-send 15 | 16 | args: 17 | app_name: Applicarion name 18 | urgency: Specifies the notification urgency level (low, normal, critical). 19 | default: normal 20 | app_icon: Application icon 21 | icon: Custom path/to/icon to display 22 | summary: Summary of notification 23 | body: Body of notification 24 | actions: list of actions to display in notification with {id, label} 25 | 26 | """ 27 | actions_array = actions_array or [] 28 | 29 | command = [ 30 | "notify-send", 31 | "-u", urgency, 32 | "-i", app_icon, 33 | summary, 34 | body, 35 | "-a", app_name, 36 | *[f'--action={v["id"]}={v["label"]}' for v in actions_array] 37 | ] 38 | 39 | command = [arg for arg in command if arg] 40 | 41 | try: 42 | subprocess.run(command, check=True) 43 | except subprocess.SubprocessError as err: 44 | logging.log(LogLevel.Error, f"Error sending notification: {err}") 45 | -------------------------------------------------------------------------------- /src/tools/swaywm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import subprocess 5 | 6 | CONFIG_FILES = [ 7 | Path.home() / ".config/sway/config", 8 | Path.home() / ".config/sway/autostart" 9 | ] 10 | 11 | def get_sway_startup_apps(): 12 | """Get apps started using exec in Sway""" 13 | startup_apps = {} 14 | for sway_config in CONFIG_FILES: 15 | if sway_config.exists(): 16 | with open(sway_config, "r") as f: 17 | lines = f.readlines() 18 | for i, line in enumerate(lines): 19 | stripped = line.strip() 20 | # this is a workaround for sway files 21 | if (stripped.startswith("exec") or 22 | stripped.startswith("# exec") or 23 | stripped.startswith("#exec") or 24 | stripped.startswith("exec_always") or 25 | stripped.startswith("#exec_always") or 26 | stripped.startswith("# exec_always")): 27 | 28 | enabled = not stripped.startswith("#") 29 | cleared_line = stripped.lstrip("#").strip() 30 | 31 | parts = cleared_line.split(None, 1) 32 | if len(parts) <= 1: 33 | continue 34 | command = parts[1].strip().strip('"') 35 | if command: 36 | startup_apps[command] = { 37 | "name": command, 38 | "type": "sway", 39 | "path": sway_config, 40 | "line_index": i, 41 | "enabled": enabled 42 | } 43 | return startup_apps 44 | 45 | def toggle_sway_startup(command): 46 | """Toggle the startup app in sway""" 47 | apps = get_sway_startup_apps() 48 | if command not in apps: 49 | (f"Command '{command}' not found in Sway autostart apps.") 50 | return 51 | 52 | app_info = apps[command] 53 | config_path = app_info["path"] 54 | 55 | with open(config_path, "r") as f: 56 | lines = f.readlines() 57 | 58 | # comment and uncomment the line 59 | if app_info["enabled"]: 60 | lines[app_info["line_index"]] = f"# {lines[app_info['line_index']].lstrip('# ').strip()}\n" 61 | print(f"Disabled startup for: {command}") 62 | else: 63 | lines[app_info["line_index"]] = lines[app_info["line_index"]].lstrip("# ").strip() + "\n" 64 | print(f"Enabled startup for: {command}") 65 | 66 | with open(config_path, "w") as f: 67 | f.writelines(lines) 68 | 69 | subprocess.run(["swaymsg", "reload"], check=True) -------------------------------------------------------------------------------- /src/tools/system.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | import shutil 5 | import psutil 6 | from typing import List, Optional, Dict, Tuple 7 | 8 | from utils.logger import LogLevel, Logger 9 | 10 | 11 | def check_dependency( 12 | command: str, name: str, install_instructions: str, logging: Logger 13 | ) -> Optional[str]: 14 | """Checks if a dependency is installed or not 15 | 16 | Args: 17 | command (str): command used to check for the dependency 18 | name (str): the package name of the dependency 19 | install_instructions (str): the instruction used to install the package 20 | 21 | Returns: 22 | Optional[str]: Error message if dependency is missing, None otherwise 23 | """ 24 | if not shutil.which(command): 25 | error_msg = f"{name} is required but not installed!\n\nInstall it using:\n{install_instructions}" 26 | logging.log(LogLevel.Error, error_msg) 27 | return error_msg 28 | return None 29 | 30 | 31 | def get_battery_devices(logging: Logger) -> List[str]: 32 | """Returns battery devices that are detected by upower 33 | 34 | Returns: 35 | List[str]: a list of devices 36 | """ 37 | try: 38 | devices = subprocess.getoutput("upower -e").split("\n") 39 | return [device for device in devices if "battery" in device] 40 | except Exception as e: 41 | logging.log(LogLevel.Error, f"Error retrieving battery devices: {e}") 42 | return [] 43 | 44 | 45 | def get_battery_info(device: str, logging: Logger) -> str: 46 | """fetch the information of a battery 47 | 48 | Args: 49 | device (str): the name of the battery device 50 | 51 | Returns: 52 | str: the information of the device 53 | """ 54 | try: 55 | info = subprocess.getoutput(f"upower -i {device}") 56 | return info 57 | except Exception as e: 58 | logging.log(LogLevel.Error, f"Error retrieving battery info for {device}: {e}") 59 | return "" 60 | 61 | 62 | def get_system_battery_info() -> Optional[Dict[str, str]]: 63 | """fetch the systemwide battery information from psutil 64 | 65 | Returns: 66 | Optional[Dict[str, str]]: a dictionary containing charge, state, and time left. Or None 67 | """ 68 | battery = psutil.sensors_battery() 69 | 70 | if not battery: 71 | return None 72 | 73 | # Get charging state 74 | state = "Charging" if battery.power_plugged else "Discharging" 75 | 76 | # Get the amount of time left for the battery 77 | if battery.secsleft == psutil.POWER_TIME_UNLIMITED: 78 | time_left = "Unlimited" 79 | else: 80 | hours, remainder = divmod(battery.secsleft, 3600) 81 | minutes = remainder // 60 82 | time_left = f"{hours}h {minutes}m" 83 | 84 | return { 85 | "Charge": f"{battery.percent}%", 86 | "State": state, 87 | "Time Left": time_left, 88 | } 89 | 90 | 91 | def detect_peripheral_battery(logging: Logger) -> Tuple[Optional[str], Optional[str]]: 92 | """get the battery status of mices and keyboards 93 | 94 | Returns: 95 | Tuple: a tuple containing the device and the label 96 | """ 97 | for device in get_battery_devices(logging): 98 | info = get_battery_info(device, logging) 99 | if "mouse" in info.lower(): 100 | return (device, "Wireless Mouse Battery") 101 | elif "keyboard" in info.lower(): 102 | return (device, "Wireless Keyboard Battery") 103 | return (None, None) 104 | 105 | 106 | def get_battery_status(logging: Logger) -> Dict[Optional[str], Optional[str]]: 107 | """fetch the battery status of peripheral given from the "detect_peripheral_battery" function 108 | 109 | Example Usage: 110 | ```python 111 | battery_status = get_battery_status() 112 | ``` 113 | 114 | Returns: 115 | Dict[str, str]: a dict containing the device label and battery charge 116 | """ 117 | peripheral_battery, label = detect_peripheral_battery(logging) 118 | 119 | if peripheral_battery: 120 | info = get_battery_info(peripheral_battery, logging) 121 | percentage = next( 122 | ( 123 | line.split(":")[1].strip() 124 | for line in info.split("\n") 125 | if "percentage" in line.lower() 126 | ), 127 | "Unknown", 128 | ) 129 | return {"Device": label, "Charge": percentage} 130 | 131 | system_battery = get_system_battery_info() 132 | if system_battery: 133 | system_battery["Device"] = "System Battery" 134 | return system_battery # type: ignore 135 | 136 | return {"Device": "No Battery Detected"} 137 | 138 | 139 | def get_current_brightness(logging: Logger) -> int: 140 | """Get the current brightness level. 141 | 142 | Returns: 143 | int: Current brightness level (0-100) 144 | """ 145 | if not shutil.which("brightnessctl"): 146 | logging.log(LogLevel.Error, "brightnessctl is not installed.") 147 | return 50 148 | 149 | output = subprocess.getoutput("brightnessctl get") 150 | max_brightness = subprocess.getoutput("brightnessctl max") 151 | 152 | try: 153 | return int((int(output) / int(max_brightness)) * 100) 154 | except ValueError: 155 | logging.log( 156 | LogLevel.Error, 157 | f"Unexpected output from brightnessctl: {output}, {max_brightness}", 158 | ) 159 | return 50 160 | 161 | 162 | def set_brightness_level(value: int, logging: Logger) -> None: 163 | """Set brightness to a specific level. 164 | 165 | Args: 166 | value (int): Brightness level to set (0-100) 167 | """ 168 | if shutil.which("brightnessctl"): 169 | max_brightness = int(subprocess.getoutput("brightnessctl max")) 170 | # Convert percentage to actual brightness value 171 | actual_value = int((value / 100) * max_brightness) 172 | subprocess.run( 173 | ["brightnessctl", "s", f"{actual_value}"], stdout=subprocess.DEVNULL 174 | ) 175 | else: 176 | logging.log( 177 | LogLevel.Error, 178 | "brightnessctl is missing. Please check our GitHub page to see all dependencies and install them", 179 | ) 180 | -------------------------------------------------------------------------------- /src/tools/terminal.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | def term_support_color() -> bool: 5 | result = subprocess.run(["tput", "colors"], capture_output=True, text=True).stdout 6 | return result.strip() == "256" 7 | -------------------------------------------------------------------------------- /src/tools/wifi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import qrcode 5 | import subprocess 6 | from typing import List, Dict 7 | 8 | from utils.logger import LogLevel, Logger 9 | import time 10 | import threading 11 | 12 | 13 | def get_wifi_status(logging: Logger) -> bool: 14 | """Get WiFi power status 15 | 16 | Returns: 17 | bool: True if WiFi is enabled, False otherwise 18 | """ 19 | try: 20 | result = subprocess.run( 21 | ["nmcli", "radio", "wifi"], capture_output=True, text=True 22 | ) 23 | return result.stdout.strip().lower() == "enabled" 24 | except Exception as e: 25 | logging.log(LogLevel.Error, f"Failed getting WiFi status: {e}") 26 | return False 27 | 28 | 29 | def set_wifi_power(enabled: bool, logging: Logger) -> None: 30 | """Set WiFi power state 31 | 32 | Args: 33 | enabled (bool): True to enable, False to disable 34 | """ 35 | try: 36 | state = "on" if enabled else "off" 37 | subprocess.run(["nmcli", "radio", "wifi", state], check=True) 38 | except subprocess.CalledProcessError as e: 39 | logging.log(LogLevel.Error, f"Failed setting WiFi power: {e}") 40 | 41 | 42 | def get_wifi_networks(logging: Logger) -> List[Dict[str, str]]: 43 | """Get list of available WiFi networks 44 | 45 | Returns: 46 | List[Dict[str, str]]: List of network dictionaries 47 | """ 48 | try: 49 | # Check if WiFi is supported on this system 50 | result = subprocess.run( 51 | ["nmcli", "-t", "-f", "DEVICE,TYPE", "device"], 52 | capture_output=True, 53 | text=True, 54 | ) 55 | wifi_interfaces = [ 56 | line for line in result.stdout.split("\n") if "wifi" in line] 57 | if not wifi_interfaces: 58 | logging.log(LogLevel.Warn, "WiFi is not supported on this machine") 59 | return [] 60 | 61 | # Use --terse mode and specific fields for more reliable parsing 62 | result = subprocess.run( 63 | [ 64 | "nmcli", 65 | "-t", 66 | "-f", 67 | "IN-USE,SSID,SIGNAL,SECURITY", 68 | "device", 69 | "wifi", 70 | "list", 71 | ], 72 | capture_output=True, 73 | text=True, 74 | ) 75 | output = result.stdout 76 | networks = [] 77 | for line in output.split("\n"): 78 | if not line.strip(): 79 | continue 80 | # Split by ':' since we're using terse mode 81 | parts = line.split(":") 82 | if len(parts) >= 4: 83 | in_use = "*" in parts[0] 84 | ssid = parts[1] 85 | signal = parts[2] if parts[2].strip() else "0" 86 | security = parts[3] if parts[3].strip() != "" else "none" 87 | # Only add networks with valid SSIDs 88 | if ssid and ssid.strip(): 89 | networks.append( 90 | { 91 | "in_use": in_use, 92 | "ssid": ssid.strip(), 93 | "signal": signal.strip(), 94 | "security": security.strip(), 95 | } 96 | ) 97 | return networks 98 | except Exception as e: 99 | logging.log(LogLevel.Error, f"Failed getting WiFi networks: {e}") 100 | return [] 101 | 102 | 103 | def get_connection_info(ssid: str, logging: Logger) -> Dict[str, str]: 104 | """Get information about a WiFi connection 105 | 106 | Args: 107 | ssid (str): Network SSID 108 | 109 | Returns: 110 | Dict[str, str]: Dictionary containing connection information 111 | """ 112 | try: 113 | result = subprocess.run( 114 | ["nmcli", "-t", "--show-secrets", "connection", "show", ssid], capture_output=True, text=True 115 | ) 116 | output = result.stdout 117 | info = {} 118 | for line in output.split("\n"): 119 | if ":" in line: 120 | key, value = line.split(":", 1) 121 | info[key.strip()] = value.strip() 122 | password = info.get("802-11-wireless-security.psk", "Hidden") 123 | info["password"] = password 124 | return info 125 | except Exception as e: 126 | logging.log(LogLevel.Error, f"Failed getting connection info: {e}") 127 | return {} 128 | 129 | 130 | def get_network_details(logging: Logger) -> Dict[str, str]: 131 | """Get current network details: IP address, DNS, and gateway""" 132 | 133 | details = {"ip_address": "N/A", "dns": "N/A", "gateway": "N/A"} 134 | try: 135 | result = subprocess.run( 136 | ["nmcli", "-t", "-f", "NAME,DEVICE,STATE", 137 | "connection", "show", "--active"], 138 | capture_output=True, 139 | text=True, 140 | ) 141 | active_connections = result.stdout.strip().split("\n") 142 | connection_name = None 143 | device_name = None 144 | for line in active_connections: 145 | parts = line.split(":") 146 | if len(parts) >= 3 and parts[2] == "activated": 147 | connection_name = parts[0] 148 | device_name = parts[1] 149 | break 150 | if not connection_name or not device_name: 151 | return details 152 | 153 | # Get IP addres 154 | ip_result = subprocess.run( 155 | ["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", device_name], 156 | capture_output=True, 157 | text=True, 158 | ) 159 | for line in ip_result.stdout.strip().split("\n"): 160 | if line.startswith("IP4.ADDRESS"): 161 | ip = line.split(":", 1)[1].split("/")[0] 162 | details["ip_address"] = ip 163 | break 164 | 165 | # Get DNS server 166 | dns_result = subprocess.run( 167 | ["nmcli", "-t", "-f", "IP4.DNS", "device", "show", device_name], 168 | capture_output=True, 169 | text=True, 170 | ) 171 | dns_servers = [] 172 | for line in dns_result.stdout.strip().split("\n"): 173 | if line.startswith("IP4.DNS"): 174 | dns_servers.append(line.split(":", 1)[1]) 175 | if dns_servers: 176 | details["dns"] = ", ".join(dns_servers) 177 | 178 | # Get gateway 179 | gw_result = subprocess.run( 180 | ["nmcli", "-t", "-f", "IP4.GATEWAY", "device", "show", device_name], 181 | capture_output=True, 182 | text=True, 183 | ) 184 | for line in gw_result.stdout.strip().split("\n"): 185 | if line.startswith("IP4.GATEWAY"): 186 | details["gateway"] = line.split(":", 1)[1] 187 | break 188 | 189 | return details 190 | except Exception as e: 191 | logging.log(LogLevel.Error, f"Failed getting network details: {e}") 192 | return details 193 | 194 | 195 | def connect_network( 196 | ssid: str, logging: Logger, password: str = "", remember: bool = True 197 | ) -> bool: 198 | """Connect to a WiFi network 199 | 200 | Args: 201 | ssid (str): Network SSID 202 | password (str, optional): Network password. Defaults to "". 203 | remember (bool, optional): Whether to save the connection. Defaults to True. 204 | 205 | Returns: 206 | bool: True if connection successful, False otherwise 207 | """ 208 | # Handle connection with password 209 | if password: 210 | return _connect_with_password(ssid, password, remember, logging) 211 | else: 212 | return _connect_without_password(ssid, remember, logging) 213 | 214 | 215 | def _connect_with_password(ssid: str, password: str, remember: bool, logging: Logger) -> bool: 216 | """Helper function to connect to a network with a password""" 217 | try: 218 | # Get networks but don't use the unused variable 219 | get_wifi_networks(logging) 220 | 221 | # Default to creating a new connection with explicit security settings 222 | logging.log(LogLevel.Info, f"Creating connection for network: {ssid}") 223 | 224 | # First, try to delete any existing connection with this name to avoid conflicts 225 | try: 226 | delete_cmd = f'nmcli connection delete "{ssid}"' 227 | subprocess.run(delete_cmd, shell=True, 228 | capture_output=True, text=True) 229 | logging.log(LogLevel.Debug, 230 | f"Removed any existing connection named '{ssid}'") 231 | except Exception as e: 232 | # It's fine if this fails - might not exist yet 233 | logging.log( 234 | LogLevel.Debug, f"No existing connection to delete or other error: {e}") 235 | 236 | # Create a new connection with the right security settings 237 | conn_name = f"{ssid}-temp" if not remember else ssid 238 | 239 | # Create new connection with explicit security settings 240 | cmd_str = f'nmcli connection add con-name "{conn_name}" type wifi ssid "{ssid}" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "{password}"' 241 | logging.log( 242 | LogLevel.Debug, f"Creating connection with command (password masked): nmcli connection add con-name \"{conn_name}\" type wifi ssid \"{ssid}\" wifi-sec.key-mgmt wpa-psk wifi-sec.psk ********") 243 | 244 | result = subprocess.run( 245 | cmd_str, capture_output=True, text=True, shell=True) 246 | 247 | # Log the result 248 | if result.stdout: 249 | logging.log(LogLevel.Debug, 250 | f"Connection creation output: {result.stdout}") 251 | if result.stderr: 252 | logging.log(LogLevel.Debug, 253 | f"Connection creation error: {result.stderr}") 254 | 255 | # Now activate the connection 256 | if result.returncode == 0: 257 | return _activate_connection(conn_name, ssid, remember, logging) 258 | else: 259 | logging.log( 260 | LogLevel.Error, f"Failed to create connection profile: {result.stderr or 'Unknown error'}") 261 | return _try_fallback_connection(ssid, password, remember, logging) 262 | 263 | except Exception as e: 264 | logging.log(LogLevel.Error, f"Error during connection process: {e}") 265 | return False 266 | 267 | 268 | def _activate_connection(conn_name: str, ssid: str, remember: bool, logging: Logger) -> bool: 269 | """Activate a created connection profile""" 270 | logging.log(LogLevel.Info, 271 | f"Created connection profile for {ssid}, now connecting...") 272 | 273 | # Connect to the newly created connection 274 | up_cmd = f'nmcli connection up "{conn_name}"' 275 | up_result = subprocess.run( 276 | up_cmd, capture_output=True, text=True, shell=True) 277 | 278 | # Log the connection result 279 | if up_result.stdout: 280 | logging.log(LogLevel.Debug, 281 | f"Connection activation output: {up_result.stdout}") 282 | if up_result.stderr: 283 | logging.log(LogLevel.Debug, 284 | f"Connection activation error: {up_result.stderr}") 285 | 286 | # Clean up if temporary 287 | if not remember and up_result.returncode == 0: 288 | _schedule_connection_cleanup(conn_name, logging) 289 | 290 | if up_result.returncode == 0: 291 | logging.log(LogLevel.Info, f"Successfully connected to {ssid}") 292 | return True 293 | else: 294 | logging.log( 295 | LogLevel.Error, f"Failed to activate connection: {up_result.stderr or 'Unknown error'}") 296 | return False 297 | 298 | 299 | def _schedule_connection_cleanup(conn_name: str, logging: Logger) -> None: 300 | """Schedule the deletion of a temporary connection""" 301 | def delete_later(): 302 | try: 303 | time.sleep(2) # Give it a moment to connect fully 304 | delete_cmd = f'nmcli connection delete "{conn_name}"' 305 | subprocess.run(delete_cmd, shell=True) 306 | logging.log(LogLevel.Debug, 307 | f"Removed temporary connection {conn_name}") 308 | except Exception as e: 309 | logging.log(LogLevel.Error, 310 | f"Failed to remove temporary connection: {e}") 311 | 312 | cleanup = threading.Thread(target=delete_later) 313 | cleanup.daemon = True 314 | cleanup.start() 315 | 316 | 317 | def _try_fallback_connection(ssid: str, password: str, remember: bool, logging: Logger) -> bool: 318 | """Try the simpler device wifi connect approach as fallback""" 319 | fallback_cmd = f'nmcli device wifi connect "{ssid}" password "{password}"' 320 | if not remember: 321 | fallback_cmd += " --temporary" 322 | 323 | logging.log(LogLevel.Debug, 324 | f"Trying fallback connection method (password masked): nmcli device wifi connect \"{ssid}\" password ********") 325 | fallback_result = subprocess.run( 326 | fallback_cmd, capture_output=True, text=True, shell=True) 327 | 328 | if fallback_result.returncode == 0: 329 | logging.log(LogLevel.Info, 330 | f"Connected to {ssid} using fallback method") 331 | return True 332 | else: 333 | logging.log( 334 | LogLevel.Error, f"Fallback connection failed: {fallback_result.stderr or 'Unknown error'}") 335 | return False 336 | 337 | 338 | def _connect_without_password(ssid: str, remember: bool, logging: Logger) -> bool: 339 | """Connect to a network without providing a password (using saved credentials)""" 340 | try: 341 | # Use shell=True with quoted SSID 342 | cmd_str = f'nmcli con up "{ssid}"' 343 | result = subprocess.run( 344 | cmd_str, capture_output=True, text=True, shell=True) 345 | 346 | # Log all output 347 | if result.stdout: 348 | logging.log(LogLevel.Debug, 349 | f"Saved connection output: {result.stdout}") 350 | if result.stderr: 351 | logging.log(LogLevel.Debug, 352 | f"Saved connection error: {result.stderr}") 353 | 354 | # Check result code 355 | if result.returncode == 0: 356 | logging.log(LogLevel.Info, 357 | f"Connected to {ssid} using saved connection") 358 | return True 359 | else: 360 | # Log the error details for debugging 361 | logging.log( 362 | LogLevel.Debug, f"Failed to connect using saved connection: {result.stderr}") 363 | 364 | # Check if the error is about missing password 365 | if "Secrets were required, but not provided" in (result.stderr or ""): 366 | logging.log( 367 | LogLevel.Debug, "Connection requires password which wasn't provided") 368 | # Return false to trigger the password dialog in the UI 369 | return False 370 | 371 | return _try_direct_connection(ssid, remember, logging) 372 | except Exception as e: 373 | logging.log(LogLevel.Error, 374 | f"Exception during connection attempt: {e}") 375 | return False 376 | 377 | 378 | def _try_direct_connection(ssid: str, remember: bool, logging: Logger) -> bool: 379 | """Try connecting directly to a network""" 380 | cmd_str = f'nmcli device wifi connect "{ssid}"' 381 | if not remember: 382 | cmd_str += " --temporary" 383 | 384 | logging.log(LogLevel.Debug, 385 | f"Attempting direct connection using command: {cmd_str}") 386 | result = subprocess.run( 387 | cmd_str, capture_output=True, text=True, shell=True) 388 | 389 | # Log all output 390 | if result.stdout: 391 | logging.log(LogLevel.Debug, 392 | f"Direct connection output: {result.stdout}") 393 | if result.stderr: 394 | logging.log(LogLevel.Debug, 395 | f"Direct connection error: {result.stderr}") 396 | 397 | # Check result code 398 | if result.returncode == 0: 399 | logging.log(LogLevel.Info, 400 | f"Connected to {ssid} using direct connection") 401 | return True 402 | else: 403 | logging.log( 404 | LogLevel.Error, f"Failed direct connection: {result.stderr or 'Unknown error'}") 405 | return False 406 | 407 | 408 | def disconnect_network(ssid: str, logging: Logger) -> bool: 409 | """Disconnect from a WiFi network 410 | 411 | Args: 412 | ssid (str): Network SSID 413 | 414 | Returns: 415 | bool: True if disconnection successful, False otherwise 416 | """ 417 | try: 418 | subprocess.run(["nmcli", "connection", "down", ssid], check=True) 419 | return True 420 | except subprocess.CalledProcessError as e: 421 | logging.log(LogLevel.Error, f"Failed disconnecting from network: {e}") 422 | return False 423 | 424 | 425 | def forget_network(ssid: str, logging: Logger) -> bool: 426 | """Remove a saved WiFi network 427 | 428 | Args: 429 | ssid (str): Network SSID 430 | 431 | Returns: 432 | bool: True if removal successful, False otherwise 433 | """ 434 | try: 435 | subprocess.run(["nmcli", "connection", "delete", ssid], check=True) 436 | return True 437 | except subprocess.CalledProcessError as e: 438 | logging.log(LogLevel.Error, f"Failed removing network: {e}") 439 | return False 440 | 441 | 442 | def get_network_speed(logging: Logger) -> Dict[str, float]: 443 | try: 444 | # Get network interfaces for wifi and ethernett 445 | interfaces_output = subprocess.getoutput( 446 | "nmcli -t -f DEVICE,TYPE device | grep -E 'wifi|ethernet'" 447 | ).split("\n") 448 | wifi_interfaces = [line.split( 449 | ":")[0] for line in interfaces_output if "wifi" in line and ":" in line] 450 | ethernet_interfaces = [line.split( 451 | ":")[0] for line in interfaces_output if "ethernet" in line and ":" in line] 452 | 453 | def is_interface_up(interface: str) -> bool: 454 | operstate = subprocess.getoutput( 455 | f"cat /sys/class/net/{interface}/operstate").strip() 456 | return operstate == "up" 457 | 458 | interface = None 459 | for iface in wifi_interfaces: 460 | if is_interface_up(iface): 461 | interface = iface 462 | break 463 | 464 | if interface is None: 465 | for iface in ethernet_interfaces: 466 | if is_interface_up(iface): 467 | interface = iface 468 | break 469 | 470 | if interface is None: 471 | return {"rx_bytes": 0, "tx_bytes": 0, "wifi_supported": False} 472 | 473 | rx_bytes = int( 474 | subprocess.getoutput( 475 | f"cat /sys/class/net/{interface}/statistics/rx_bytes") 476 | ) 477 | tx_bytes = int( 478 | subprocess.getoutput( 479 | f"cat /sys/class/net/{interface}/statistics/tx_bytes") 480 | ) 481 | 482 | return {"rx_bytes": rx_bytes, "tx_bytes": tx_bytes, "wifi_supported": True} 483 | except Exception as e: 484 | logging.log(LogLevel.Error, f"Failed getting network speed: {e}") 485 | return {"rx_bytes": 0, "tx_bytes": 0, "wifi_supported": False} 486 | 487 | 488 | def get_pillow_install_instructions(): 489 | """Returns installation instructions for Pillow on various distros""" 490 | instructions = ( 491 | "Pillow (Python Imaging Library) is required for QR code generation.\n" 492 | "Install it for your distribution:\n\n" 493 | "Alpine: sudo apk add py3-pillow\n" 494 | "Fedora: sudo dnf install python3-pillow\n" 495 | "Debian/Ubuntu: sudo apt install python3-pillow\n" 496 | "Arch: sudo pacman -S python-pillow\n" 497 | "Void: sudo xbps-install python3-pillow\n\n" 498 | "After installing, restart the application." 499 | ) 500 | print(instructions) 501 | 502 | 503 | def generate_wifi_qrcode(ssid: str, password: str, security: str, logging: Logger) -> str: 504 | """Generate qr_code for the wifi 505 | 506 | Returns: 507 | str: path to generated qr code image or installation instructions 508 | """ 509 | # First check if Pillow is available 510 | try: 511 | import qrcode 512 | except ImportError: 513 | get_pillow_install_instructions() 514 | 515 | # Define temp_dir at the beginning to avoid 'possibly unbound' error 516 | temp_dir = Path("/tmp/better-control") 517 | 518 | try: 519 | temp_dir.mkdir(parents=True, exist_ok=True) 520 | 521 | qr_code_path = temp_dir / f"{ssid}.png" 522 | if qr_code_path.exists(): 523 | logging.log(LogLevel.Info, 524 | f"found qr code for {ssid} at {qr_code_path}") 525 | return str(qr_code_path) 526 | 527 | security_type = "WPA" if security.lower() != "none" else "nopass" 528 | wifi_string = f"WIFI:T:{security_type};S:{ssid};P:{password};;" 529 | 530 | # generate the qr code 531 | # generate the qr code 532 | qr_code = qrcode.QRCode( 533 | version=1, 534 | error_correction=qrcode.ERROR_CORRECT_L, 535 | box_size=6, 536 | border=2, 537 | ) 538 | qr_code.add_data(wifi_string) 539 | qr_code.make(fit=True) 540 | 541 | # create qr code image 542 | qr_code_image = qr_code.make_image( 543 | fill_color="black", back_color="white") 544 | # Open the file in binary write mode instead of passing a string 545 | with open(qr_code_path, "wb") as f: 546 | qr_code_image.save(f) 547 | 548 | logging.log(LogLevel.Info, 549 | f"generated qr code for {ssid} at {qr_code_path}") 550 | return str(qr_code_path) 551 | 552 | except Exception as e: 553 | logging.log(LogLevel.Error, 554 | f"Failed to generate qr code for {ssid} : {e}") 555 | # Fix: create an error.png path using correct Path object usage 556 | error_path = temp_dir / "error.png" 557 | return str(error_path) 558 | 559 | 560 | def wifi_supported() -> bool: 561 | try: 562 | result = subprocess.run( 563 | ["nmcli", "-t", "-f", "DEVICE,TYPE", "device"], capture_output=True, text=True) 564 | wifi_interfaces = [ 565 | line for line in result.stdout.split('\n') if "wifi" in line] 566 | return bool(wifi_interfaces) 567 | except Exception: 568 | return False 569 | -------------------------------------------------------------------------------- /src/ui/css/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # CSS package initialization 3 | -------------------------------------------------------------------------------- /src/ui/css/animations.css: -------------------------------------------------------------------------------- 1 | /* Animation classes for widget transitions */ 2 | .animate-show { 3 | opacity: 0; 4 | transition: opacity 200ms ease-in; 5 | } 6 | 7 | .animate-show:active, 8 | .animate-show:focus, 9 | .animate-show:hover { 10 | opacity: 1; 11 | } 12 | 13 | /* Fade-in animation */ 14 | @keyframes fadeIn { 15 | from { opacity: 0; } 16 | to { opacity: 1; } 17 | } 18 | 19 | .fade-in { 20 | animation: fadeIn 200ms ease-in forwards; 21 | } 22 | 23 | /* Tab container styling */ 24 | notebook tab { 25 | margin: 4px; 26 | padding: 4px 12px; 27 | } 28 | 29 | notebook tab:active, 30 | notebook tab:checked { 31 | margin: 4px; 32 | padding: 4px 12px; 33 | } 34 | 35 | /* Content area padding */ 36 | notebook > stack { 37 | padding: 12px; 38 | } 39 | 40 | /* Rotate the gear icon */ 41 | .rotate-gear { 42 | transition: all 200ms ease; 43 | -gtk-icon-transform: rotate(0deg); 44 | } 45 | 46 | .rotate-gear-active{ 47 | -gtk-icon-transform: rotate(360deg); 48 | transition: all 600ms ease; 49 | } 50 | 51 | @keyframes pulse { 52 | 0% { 53 | -gtk-icon-transform: scale(1); 54 | opacity: 1; 55 | } 56 | 50% { 57 | -gtk-icon-transform: scale(1.05); 58 | opacity: 0.8; 59 | } 60 | 100% { 61 | -gtk-icon-transform: scale(1); 62 | opacity: 1; 63 | } 64 | } 65 | 66 | 67 | .volume-icon-animate { 68 | animation: pulse 600ms ease-in-out; 69 | } 70 | 71 | /* General tab icon animations */ 72 | .tab-icon { 73 | transition: all 200ms ease-in; 74 | opacity: 1; 75 | } 76 | 77 | .tab-icon:hover { 78 | opacity: 0.8; 79 | } 80 | 81 | .tab-icon:active { 82 | opacity: 1; 83 | } 84 | 85 | .tab-icon-animate { 86 | animation: pulse 600ms ease-in-out; 87 | } 88 | 89 | /* WiFi specific icon animations */ 90 | .wifi-icon { 91 | transition: all 200ms ease-in; 92 | } 93 | 94 | 95 | .wifi-icon-animate { 96 | animation: pulse 600ms ease-in-out; 97 | } 98 | 99 | /* Specific icon animations */ 100 | .bluetooth-icon { 101 | transition: all 200ms ease-in; 102 | } 103 | 104 | .bluetooth-icon-animate { 105 | animation: pulse 600ms ease-in-out; 106 | } 107 | 108 | .battery-icon { 109 | transition: all 200ms ease-in; 110 | } 111 | 112 | .battery-icon-animate { 113 | animation: pulse 600ms ease-in-out; 114 | } 115 | 116 | .display-icon { 117 | transition: all 200ms ease-in; 118 | } 119 | 120 | .display-icon-animate { 121 | animation: pulse 600ms ease-in-out; 122 | } 123 | 124 | .power-icon { 125 | transition: all 200ms ease-in; 126 | -gtk-icon-transform: rotate(0deg); 127 | } 128 | 129 | .power-icon-animate { 130 | animation: pulse 600ms ease-in-out; 131 | -gtk-icon-transform: rotate(360deg); 132 | } 133 | 134 | .settings-icon { 135 | transition: all 200ms ease-in; 136 | -gtk-icon-transform: rotate(0deg); 137 | } 138 | 139 | .settings-icon-animate { 140 | animation: pulse 600ms ease-in-out; 141 | -gtk-icon-transform: rotate(360deg); 142 | } 143 | 144 | .usb-icon { 145 | transition: all 200ms ease-in; 146 | } 147 | 148 | .usb-icon-animate { 149 | animation: pulse 600ms ease-in-out; 150 | } 151 | 152 | .autostart-icon { 153 | transition: all 200ms ease-in; 154 | } 155 | 156 | .autostart-icon-animate { 157 | animation: pulse 600ms ease-in-out; 158 | } 159 | 160 | 161 | expander { 162 | padding: 6px; 163 | border-radius: 6px; 164 | } 165 | 166 | /* Connection status indicator */ 167 | .connection-indicator { 168 | border-radius: 50%; 169 | margin: 0 8px 0 0; 170 | min-width: 16px; 171 | min-height: 16px; 172 | transition: all 0.3s ease; 173 | } 174 | 175 | .device-status-container { 176 | margin-left: 4px; 177 | padding: 1px 4px; 178 | border-radius: 3px; 179 | background-color: rgba(0,0,0,0.05); 180 | } 181 | 182 | .connection-indicator { 183 | border-radius: 50%; 184 | min-width: 10px; 185 | min-height: 10px; 186 | margin-right: 2px; 187 | } 188 | 189 | .connection-indicator.connected { 190 | background-color: #2ecc71; /* Green */ 191 | box-shadow: 0 0 8px rgba(46, 204, 113, 0.5); 192 | } 193 | 194 | .connection-indicator.disconnected { 195 | background-color: #e74c3c; /* Red */ 196 | } 197 | 198 | .status-label { 199 | font-size: 0.9em; 200 | opacity: 0.83; 201 | margin: 0 0 0 -20px; 202 | } 203 | 204 | .connection-label { 205 | font-size: 0.9em; 206 | color: #7f8c8d; 207 | margin-right: 5px; 208 | font-weight: 500; 209 | } 210 | 211 | /* Battery percentage styling */ 212 | .battery-percentage { 213 | font-size: 0.8em; 214 | color: #7f8c8d; 215 | margin-left: 2px; 216 | } 217 | 218 | /* Battery level animations */ 219 | @keyframes battery-pulse { 220 | 0% { opacity: 1; } 221 | 50% { opacity: 0.7; } 222 | 100% { opacity: 1; } 223 | } 224 | 225 | .low-battery { 226 | animation: battery-pulse 2s infinite; 227 | color: #e74c3c; 228 | } 229 | -------------------------------------------------------------------------------- /src/ui/css/animations.py: -------------------------------------------------------------------------------- 1 | import os 2 | import gi 3 | gi.require_version('Gtk', '3.0') 4 | from gi.repository import Gtk, Gdk, GLib #type: ignore 5 | 6 | def get_animations_css_path(): 7 | return os.path.join(os.path.dirname(__file__), "animations.css") 8 | 9 | def animate_widget_show(widget, duration=200): 10 | """Animate widget appearance using CSS transitions""" 11 | style_context = widget.get_style_context() 12 | style_context.add_class("animate-show") 13 | GLib.timeout_add(duration, lambda: style_context.remove_class("animate-show")) 14 | 15 | def load_animations_css(): 16 | css_provider = Gtk.CssProvider() 17 | css_provider.load_from_path(get_animations_css_path()) 18 | 19 | try: 20 | screen = Gdk.Screen.get_default() 21 | if screen is not None: 22 | Gtk.StyleContext.add_provider_for_screen( 23 | screen, 24 | css_provider, 25 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 26 | ) 27 | else: 28 | print("Warning: No display available for CSS animations") 29 | except Exception as e: 30 | print(f"Warning: Could not load CSS animations: {str(e)}") 31 | -------------------------------------------------------------------------------- /src/ui/dialogs/rotation_dialog.py: -------------------------------------------------------------------------------- 1 | import gi 2 | gi.require_version('Gtk', '3.0') 3 | from gi.repository import Gtk, GLib # type:ignore 4 | from tools.hyprland import set_hyprland_transform 5 | 6 | class RotationConfirmDialog(Gtk.MessageDialog): 7 | def __init__(self, parent, display, previous_orientation, session, logging): 8 | super().__init__( 9 | transient_for=parent, 10 | flags=0, 11 | message_type=Gtk.MessageType.QUESTION, 12 | buttons=Gtk.ButtonsType.NONE, 13 | text="Keep display changes?" 14 | ) 15 | 16 | self.display = display 17 | self.previous_orientation = previous_orientation 18 | self.session = session 19 | self.logging = logging 20 | self.countdown = 10 21 | self.timeout_id = None 22 | 23 | # Add buttons 24 | self.add_button("Revert back", Gtk.ResponseType.CANCEL) 25 | self.add_button("Keep changes", Gtk.ResponseType.OK) 26 | self.set_default_response(Gtk.ResponseType.CANCEL) 27 | 28 | # Add countdown label 29 | self.countdown_label = Gtk.Label() 30 | self.countdown_label.set_text(f"Reverting in {self.countdown} seconds...") 31 | self.get_content_area().pack_end(self.countdown_label, True, True, 5) 32 | self.show_all() 33 | 34 | # Start countdown 35 | self.timeout_id = GLib.timeout_add(1000, self.update_countdown) 36 | 37 | def update_countdown(self): 38 | self.countdown -= 1 39 | if self.countdown <= 0: 40 | self.response(Gtk.ResponseType.CANCEL) 41 | return False 42 | 43 | self.countdown_label.set_text(f"Reverting in {self.countdown} seconds...") 44 | return True 45 | 46 | def do_response(self, response): 47 | # Clean up timer 48 | if self.timeout_id: 49 | GLib.source_remove(self.timeout_id) 50 | 51 | # Handle revert 52 | if response == Gtk.ResponseType.CANCEL: 53 | if "Hyprland" in self.session: 54 | set_hyprland_transform(self.logging, self.display, self.previous_orientation) 55 | -------------------------------------------------------------------------------- /src/ui/tabs/autostart_tab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import threading 4 | import gi 5 | 6 | from utils.translations import Translation # type: ignore 7 | gi.require_version('Gtk', '3.0') 8 | import glob 9 | import os 10 | from pathlib import Path 11 | from gi.repository import Gtk, GLib, Gdk, Pango # type: ignore 12 | from utils.logger import LogLevel, Logger 13 | from tools.hyprland import get_hyprland_startup_apps, toggle_hyprland_startup 14 | from tools.globals import get_current_session 15 | from tools.swaywm import get_sway_startup_apps, toggle_sway_startup 16 | 17 | class AutostartTab(Gtk.Box): 18 | """Autostart settings tab""" 19 | 20 | def __init__(self, logging: Logger, txt: Translation): 21 | super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10) 22 | self.txt = txt 23 | self.logging = logging 24 | self.startup_apps = {} 25 | 26 | self.update_timeout_id = None 27 | self.update_interval = 100 # in ms 28 | self.is_visible = False 29 | 30 | # Set margins to match other tabs 31 | self.set_margin_start(15) 32 | self.set_margin_end(15) 33 | self.set_margin_top(15) 34 | self.set_margin_bottom(15) 35 | self.set_hexpand(True) 36 | self.set_vexpand(True) 37 | 38 | hypr_apps = get_hyprland_startup_apps() 39 | if not hypr_apps: 40 | logging.log(LogLevel.Warn, "failed to get hyprland config") 41 | 42 | # Create header box with title 43 | header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) 44 | header_box.set_hexpand(True) 45 | header_box.set_margin_bottom(10) 46 | 47 | # Create title box with icon and label 48 | title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) 49 | 50 | # Add display icon with hover animations 51 | display_icon = Gtk.Image.new_from_icon_name( 52 | "system-run-symbolic", Gtk.IconSize.DIALOG 53 | ) 54 | ctx = display_icon.get_style_context() 55 | ctx.add_class("autostart-icon") 56 | 57 | def on_enter(widget, event): 58 | ctx.add_class("autostart-icon-animate") 59 | 60 | def on_leave(widget, event): 61 | ctx.remove_class("autostart-icon-animate") 62 | 63 | # Wrap in event box for hover detection 64 | icon_event_box = Gtk.EventBox() 65 | icon_event_box.add(display_icon) 66 | icon_event_box.connect("enter-notify-event", on_enter) 67 | icon_event_box.connect("leave-notify-event", on_leave) 68 | 69 | title_box.pack_start(icon_event_box, False, False, 0) 70 | 71 | # Add title with better styling 72 | display_label = Gtk.Label() 73 | display_label.set_markup( 74 | f"{getattr(self.txt, 'autostart_title', 'Autostart')}" 75 | ) 76 | display_label.get_style_context().add_class("header-title") 77 | display_label.set_halign(Gtk.Align.START) 78 | title_box.pack_start(display_label, False, False, 0) 79 | 80 | header_box.pack_start(title_box, True, True, 0) 81 | 82 | # Add scan button with better styling 83 | self.refresh_button = Gtk.Button() 84 | self.refresh_button.set_valign(Gtk.Align.CENTER) 85 | self.refresh_btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) 86 | self.refresh_icon = Gtk.Image.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON) 87 | self.refresh_label = Gtk.Label(label="Refresh") 88 | self.refresh_label.set_margin_start(5) 89 | self.refresh_btn_box.pack_start(self.refresh_icon, False, False, 0) 90 | 91 | # Animation controller 92 | self.refresh_revealer = Gtk.Revealer() 93 | self.refresh_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_RIGHT) 94 | self.refresh_revealer.set_transition_duration(150) 95 | self.refresh_revealer.add(self.refresh_label) 96 | self.refresh_revealer.set_reveal_child(False) 97 | self.refresh_btn_box.pack_start(self.refresh_revealer, False, False, 0) 98 | 99 | self.refresh_button.add(self.refresh_btn_box) 100 | refresh_tooltip = getattr(self.txt, "refresh_tooltip", "Refresh and Scan for Services") 101 | self.refresh_button.set_tooltip_text(refresh_tooltip) 102 | self.refresh_button.connect("clicked", self.refresh_list) 103 | 104 | # Hover behavior 105 | self.refresh_button.set_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) 106 | self.refresh_button.connect("enter-notify-event", self.on_refresh_enter) 107 | self.refresh_button.connect("leave-notify-event", self.on_refresh_leave) 108 | 109 | # Add refresh button to header 110 | header_box.pack_end(self.refresh_button, False, False, 0) 111 | self.pack_start(header_box, False, False, 0) 112 | 113 | 114 | # Add session info with badge styling 115 | current_session = get_current_session() 116 | if current_session in ["Hyprland", "sway"]: 117 | session_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) 118 | session_box.set_margin_bottom(5) 119 | 120 | session_label = Gtk.Label(label=f"{getattr(self.txt, 'autostart_session', 'Session')}: {current_session}") 121 | session_label.get_style_context().add_class("session-label") 122 | session_box.pack_start(session_label, False, False, 0) 123 | 124 | self.pack_start(session_box, False, False, 0) 125 | 126 | # Add toggle switches box with better layout 127 | toggles_frame = Gtk.Frame() 128 | toggles_frame.set_shadow_type(Gtk.ShadowType.IN) 129 | toggles_frame.set_margin_top(5) 130 | toggles_frame.set_margin_bottom(15) 131 | 132 | toggles_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) 133 | toggles_box.set_margin_start(10) 134 | toggles_box.set_margin_end(10) 135 | toggles_box.set_margin_top(10) 136 | toggles_box.set_margin_bottom(10) 137 | 138 | # System autostart apps toggle with better layout 139 | toggle1_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) 140 | toggle1_label = Gtk.Label(label=getattr(self.txt, 'autostart_show_system_apps', 'Show system apps')) 141 | toggle1_label.get_style_context().add_class("toggle-label") 142 | toggle1_label.set_halign(Gtk.Align.START) 143 | self.toggle1_switch = Gtk.Switch() 144 | self.toggle1_switch.set_active(False) 145 | self.toggle1_switch.connect("notify::active", self.on_toggle1_changed) 146 | toggle1_box.pack_start(toggle1_label, True, True, 0) 147 | toggle1_box.pack_end(self.toggle1_switch, False, False, 0) 148 | 149 | toggles_box.pack_start(toggle1_box, False, False, 0) 150 | toggles_frame.add(toggles_box) 151 | 152 | self.pack_start(toggles_frame, False, False, 0) 153 | 154 | # Add separator line with styling 155 | separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) 156 | separator.get_style_context().add_class("separator") 157 | self.pack_start(separator, False, False, 0) 158 | 159 | # Section title for apps list 160 | apps_section_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) 161 | apps_section_box.set_margin_top(5) 162 | apps_section_box.set_margin_bottom(8) 163 | 164 | apps_icon = Gtk.Image.new_from_icon_name( 165 | "application-x-executable-symbolic", Gtk.IconSize.MENU 166 | ) 167 | apps_section_box.pack_start(apps_icon, False, False, 0) 168 | 169 | apps_title = Gtk.Label() 170 | apps_title.set_markup(f"{getattr(self.txt, 'autostart_configured_applications', 'Configured Applications')}") 171 | apps_title.set_halign(Gtk.Align.START) 172 | apps_section_box.pack_start(apps_title, False, False, 0) 173 | 174 | self.pack_start(apps_section_box, False, False, 0) 175 | 176 | # Add listbox for autostart apps with better styling 177 | scrolled_window = Gtk.ScrolledWindow() 178 | scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 179 | scrolled_window.set_vexpand(True) 180 | scrolled_window.set_margin_top(5) 181 | scrolled_window.set_shadow_type(Gtk.ShadowType.IN) 182 | 183 | self.listbox = Gtk.ListBox() 184 | self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) 185 | self.listbox.set_margin_start(5) 186 | self.listbox.set_margin_end(5) 187 | self.listbox.set_margin_top(5) 188 | self.listbox.set_margin_bottom(5) 189 | # Make listbox background transparent 190 | self.listbox.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 0)) 191 | scrolled_window.add(self.listbox) 192 | self.pack_start(scrolled_window, True, True, 0) 193 | 194 | # Initial population 195 | self.refresh_list() 196 | 197 | self.connect('key-press-event', self.on_key_press) 198 | 199 | # Set up timer check for external changes 200 | GLib.timeout_add(4000, self.check_external_changes) 201 | 202 | self.connect("realize", self.on_realize) 203 | 204 | def on_realize(self, widget): 205 | GLib.idle_add(self.refresh_list) 206 | 207 | # keybinds for autostart tab 208 | def on_key_press(self, widget, event): 209 | keyval = event.keyval 210 | 211 | if keyval in (114, 82): 212 | self.logging.log(LogLevel.Info, "Refreshing list using keybind") 213 | self.refresh_list() 214 | return True 215 | 216 | def on_toggle1_changed(self, switch, gparam): 217 | """Handle toggle for system autostart apps""" 218 | show_system = switch.get_active() 219 | self.logging.log(LogLevel.Info, f"Show system autostart : {show_system}") 220 | self.refresh_list() 221 | 222 | def get_startup_apps(self): 223 | autostart_dirs = [ 224 | Path.home() / ".config/autostart" 225 | ] 226 | 227 | # Add system directories 228 | if hasattr(self, 'toggle1_switch') and self.toggle1_switch.get_active(): 229 | autostart_dirs.extend([ 230 | Path("/etc/xdg/autostart"), 231 | ]) 232 | 233 | startup_apps = {} 234 | 235 | for autostart_dir in autostart_dirs: 236 | if autostart_dir.exists(): 237 | for desktop_file in glob.glob(str(autostart_dir / "*.desktop")): 238 | if desktop_file.endswith(".desktop.disabled"): 239 | continue 240 | 241 | app_name = os.path.basename(desktop_file).replace(".desktop", "") 242 | 243 | is_hidden = False 244 | try: 245 | with open(desktop_file, 'r') as f: 246 | for line in f: 247 | if line.strip() == "Hidden=true": 248 | is_hidden = True 249 | break 250 | except Exception as e: 251 | self.logging.log(LogLevel.Warn, f"Could not read desktop file {desktop_file}: {e}") 252 | 253 | if is_hidden and hasattr(self, 'toggle2_switch') and not self.toggle2_switch.get_active(): 254 | continue 255 | startup_apps[app_name] = { 256 | "type": "desktop", 257 | "path": desktop_file, 258 | "name": app_name, 259 | "enabled": True, 260 | "hidden": is_hidden 261 | } 262 | 263 | for desktop_file in glob.glob(str(autostart_dir / "*.desktop.disabled")): 264 | app_name = os.path.basename(desktop_file).replace(".desktop.disabled", "") 265 | startup_apps[app_name] = { 266 | "type": "desktop", 267 | "path": desktop_file, 268 | "name": app_name, 269 | "enabled": False, 270 | "hidden": False 271 | } 272 | 273 | # Add hyprland and sway apps according to session 274 | if get_current_session() == "Hyprland": 275 | hypr_apps = get_hyprland_startup_apps() 276 | startup_apps.update(hypr_apps) 277 | if get_current_session() == "sway": 278 | sway_apps = get_sway_startup_apps() 279 | startup_apps.update(sway_apps) 280 | 281 | self.logging.log(LogLevel.Debug, f"Found {len(startup_apps)} autostart apps") 282 | return startup_apps 283 | 284 | def refresh_list(self, widget=None): 285 | """Clear and repopulate the list of autostart apps 286 | Args: 287 | widget: Optional widget that triggered the refresh (from GTK signals) 288 | """ 289 | # Run on a separate thread to avoid blocking the ui 290 | if hasattr(self, '_refresh_thread') and self._refresh_thread.is_alive(): 291 | self.logging.log(LogLevel.Debug, "Refresh thread is already running") 292 | thread = threading.Thread(target=self.populate_list) 293 | thread.daemon = True 294 | thread.start() 295 | 296 | def populate_list(self): 297 | # Get apps first 298 | apps = self.get_startup_apps() 299 | self.startup_apps = apps 300 | 301 | # Clear list in main thread 302 | GLib.idle_add(self.clear_list) 303 | 304 | # Add each app in main thread 305 | for app_name, app in apps.items(): 306 | GLib.idle_add(self.add_app_to_list, app_name, app) 307 | 308 | def clear_list(self): 309 | """Clear all items from the listbox""" 310 | children = self.listbox.get_children() 311 | for child in children: 312 | self.listbox.remove(child) 313 | 314 | def add_app_to_list(self, app_name, app): 315 | """Add a single app to the listbox""" 316 | self.logging.log(LogLevel.Debug, f"Adding app to list: {app_name}, enabled: {app['enabled']}") 317 | 318 | row = Gtk.ListBoxRow() 319 | row.get_style_context().add_class("app-row") 320 | 321 | # Create main container for the row 322 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) 323 | hbox.set_margin_start(8) 324 | hbox.set_margin_end(8) 325 | hbox.set_margin_top(8) 326 | hbox.set_margin_bottom(8) 327 | row.add(hbox) 328 | 329 | # Status indicator icon container 330 | status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 331 | 332 | # Try to get application icon if available 333 | app_icon = Gtk.Image.new_from_icon_name( 334 | app.get("name", app_name).lower(), Gtk.IconSize.LARGE_TOOLBAR 335 | ) 336 | # Fallback to generic icon 337 | if not app_icon.get_pixbuf(): 338 | app_icon = Gtk.Image.new_from_icon_name( 339 | "application-x-executable", Gtk.IconSize.LARGE_TOOLBAR 340 | ) 341 | app_icon.get_style_context().add_class("app-icon") 342 | status_box.pack_start(app_icon, False, False, 0) 343 | 344 | if app.get("hidden", False): 345 | hidden_icon = Gtk.Image.new_from_icon_name( 346 | "view-hidden-symbolic", Gtk.IconSize.MENU 347 | ) 348 | hidden_icon.set_tooltip_text("Hidden entry") 349 | hidden_icon.get_style_context().add_class("status-icon") 350 | status_box.pack_start(hidden_icon, False, False, 0) 351 | 352 | if not app.get("enabled", True): 353 | disabled_icon = Gtk.Image.new_from_icon_name( 354 | "window-close-symbolic", Gtk.IconSize.MENU 355 | ) 356 | disabled_icon.set_tooltip_text("Disabled") 357 | disabled_icon.get_style_context().add_class("status-icon") 358 | status_box.pack_start(disabled_icon, False, False, 0) 359 | 360 | hbox.pack_start(status_box, False, False, 0) 361 | 362 | # App info container (name and path) 363 | info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) 364 | info_box.set_hexpand(True) 365 | 366 | # App name 367 | label = Gtk.Label(label=app.get("name", app_name), xalign=0) 368 | label.set_line_wrap(True) 369 | label.set_line_wrap_mode(Pango.WrapMode.WORD) 370 | label.set_max_width_chars(40) 371 | label.get_style_context().add_class("app-label") 372 | info_box.pack_start(label, False, False, 0) 373 | 374 | # App path (if available) 375 | if "path" in app and app["path"]: 376 | path_label = Gtk.Label(label=str(app["path"]), xalign=0) 377 | path_label.set_ellipsize(Pango.EllipsizeMode.MIDDLE) 378 | path_label.get_style_context().add_class(Gtk.STYLE_CLASS_DIM_LABEL) 379 | info_box.pack_start(path_label, False, False, 0) 380 | 381 | hbox.pack_start(info_box, True, True, 0) 382 | 383 | # Toggle button with better styling 384 | button_label = self.txt.disable if app["enabled"] else self.txt.enable 385 | button = Gtk.Button(label=button_label) 386 | button.get_style_context().add_class("toggle-button") 387 | button.get_style_context().add_class("enabled" if app["enabled"] else "disabled") 388 | button.connect("clicked", self.toggle_startup, app_name) 389 | hbox.pack_end(button, False, False, 0) 390 | 391 | row.app_name = app_name 392 | row.button = button 393 | self.listbox.add(row) 394 | row.show_all() 395 | 396 | def toggle_startup(self, button, app_name): 397 | app = self.startup_apps.get(app_name) 398 | 399 | if not app: 400 | self.logging.log(LogLevel.Warn, f"App not found : {app_name}") 401 | return 402 | 403 | self.logging.log(LogLevel.Info, f"Toggling app: {app_name}, current enabled: {app.get('enabled')}, type: {app.get('type')}") 404 | 405 | # for both .desktop and hyprland apps 406 | if app["type"] == "desktop" : 407 | new_path = app["path"] + ".disabled" if app["enabled"] else app["path"].replace(".disabled", "") 408 | try: 409 | os.rename(app["path"], new_path) 410 | app["path"] = new_path 411 | app["enabled"] = not app["enabled"] 412 | button.set_label("Disable" if app["enabled"] else "Enable") 413 | 414 | style_context = button.get_style_context() 415 | if app["enabled"]: 416 | style_context.remove_class("destructive-action") 417 | style_context.add_class("suggested-action") 418 | else: 419 | style_context.remove_class("suggested-action") 420 | style_context.add_class("destructive-action") 421 | 422 | except OSError as error: 423 | self.logging.log(LogLevel.Error, f"Failed to toggle startup app: {error}") 424 | 425 | elif app["type"] == "hyprland": 426 | # hyprland specific case 427 | toggle_hyprland_startup(app_name) 428 | 429 | app["enabled"] = not app["enabled"] 430 | button.set_label(self.txt.disable if app["enabled"] else self.txt.enable) 431 | 432 | style_context = button.get_style_context() 433 | if app["enabled"]: 434 | style_context.remove_class("destructive-action") 435 | style_context.add_class("suggested-action") 436 | else: 437 | style_context.remove_class("suggested-action") 438 | style_context.add_class("destructive-action") 439 | 440 | self.logging.log(LogLevel.Info, f"App toggled: {app_name}, enabled: {app['enabled']}") 441 | # for sway 442 | elif app["type"] == "sway": 443 | # sway specific case 444 | toggle_sway_startup(app_name) 445 | 446 | app["enabled"] = not app["enabled"] 447 | button.set_label(self.txt.disable if app["enabled"] else self.txt.enable) 448 | 449 | style_context = button.get_style_context() 450 | if app["enabled"]: 451 | style_context.remove_class("destructive-action") 452 | style_context.add_class("suggested-action") 453 | else: 454 | style_context.remove_class("suggested-action") 455 | style_context.add_class("destructive-action") 456 | 457 | self.logging.log(LogLevel.Info, f"App toggled: {app_name}, enabled: {app['enabled']}") 458 | self.refresh_list() 459 | 460 | 461 | def on_scan_clicked(self, widget): 462 | self.logging.log(LogLevel.Info, "Manually refreshing autostart apps...") 463 | self.refresh_list() 464 | 465 | def check_external_changes(self): 466 | """Check for external changes and update the UI""" 467 | current_apps = self.get_startup_apps() 468 | 469 | # check if there's any difference between current and stored apps 470 | if self.has_changes(current_apps, self.startup_apps): 471 | self.logging.log(LogLevel.Info, "Detected external changes in autostart apps, updating UI") 472 | self.refresh_list() 473 | 474 | return True 475 | 476 | def has_changes(self, new_apps, old_apps): 477 | """Check if there are differences between two app dictionaries""" 478 | if set(new_apps.keys()) != set(old_apps.keys()): 479 | return True 480 | 481 | for app_name, app_info in new_apps.items(): 482 | if app_name not in old_apps: 483 | return True 484 | if app_info["enabled"] != old_apps[app_name]["enabled"]: 485 | return True 486 | if app_info["path"] != old_apps[app_name]["path"]: 487 | return True 488 | 489 | return False 490 | 491 | def on_refresh_enter(self, widget, event): 492 | alloc = widget.get_allocation() 493 | if (0 <= event.x <= alloc.width and 494 | 0 <= event.y <= alloc.height): 495 | self.refresh_revealer.set_reveal_child(True) 496 | return False 497 | 498 | def on_refresh_leave(self, widget, event): 499 | alloc = widget.get_allocation() 500 | if not (0 <= event.x <= alloc.width and 501 | 0 <= event.y <= alloc.height): 502 | self.refresh_revealer.set_reveal_child(False) 503 | return False -------------------------------------------------------------------------------- /src/ui/tabs/display_tab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import gi # type: ignore 4 | import subprocess 5 | 6 | from utils.logger import LogLevel, Logger 7 | from utils.translations import Translation 8 | 9 | gi.require_version("Gtk", "3.0") 10 | from gi.repository import Gtk, GLib # type: ignore 11 | 12 | from utils.settings import load_settings, save_settings 13 | from tools.display import get_brightness, get_displays, set_brightness 14 | from tools.globals import get_current_session 15 | 16 | class DisplayTab(Gtk.Box): 17 | """Display settings tab""" 18 | 19 | def __init__(self, logging: Logger, txt: Translation): 20 | super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10) 21 | self.txt = txt 22 | self.logging = logging 23 | self.update_timeout_id = None 24 | self.update_interval = 500 # milliseconds 25 | self.is_visible = False 26 | self.session = get_current_session() 27 | displays = get_displays(self.logging) 28 | self.current_display = displays[0] if displays else None 29 | 30 | self.set_margin_start(15) 31 | self.set_margin_end(15) 32 | self.set_margin_top(15) 33 | self.set_margin_bottom(15) 34 | self.set_hexpand(True) 35 | self.set_vexpand(True) 36 | # Create header box with title 37 | header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) 38 | header_box.set_hexpand(True) 39 | 40 | # Create title box with icon and label 41 | title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) 42 | 43 | # Add display icon with hover animations 44 | display_icon = Gtk.Image.new_from_icon_name( 45 | "video-display-symbolic", Gtk.IconSize.DIALOG 46 | ) 47 | ctx = display_icon.get_style_context() 48 | ctx.add_class("display-icon") 49 | 50 | def on_enter(widget, event): 51 | ctx.add_class("display-icon-animate") 52 | 53 | def on_leave(widget, event): 54 | ctx.remove_class("display-icon-animate") 55 | 56 | # Wrap in event box for hover detection 57 | icon_event_box = Gtk.EventBox() 58 | icon_event_box.add(display_icon) 59 | icon_event_box.connect("enter-notify-event", on_enter) 60 | icon_event_box.connect("leave-notify-event", on_leave) 61 | 62 | title_box.pack_start(icon_event_box, False, False, 0) 63 | 64 | # Add title 65 | display_label = Gtk.Label() 66 | display_label.set_markup( 67 | f"{self.txt.display_title}" 68 | ) 69 | display_label.set_halign(Gtk.Align.START) 70 | title_box.pack_start(display_label, False, False, 0) 71 | 72 | header_box.pack_start(title_box, True, True, 0) 73 | 74 | self.pack_start(header_box, False, False, 0) 75 | 76 | # Create scrollable content 77 | scroll_window = Gtk.ScrolledWindow() 78 | scroll_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 79 | scroll_window.set_vexpand(True) 80 | 81 | # Create main content box 82 | content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) 83 | content_box.set_margin_top(10) 84 | content_box.set_margin_bottom(10) 85 | content_box.set_margin_start(10) 86 | content_box.set_margin_end(10) 87 | 88 | # Brightness section 89 | brightness_label = Gtk.Label() 90 | brightness_label.set_markup(f"{self.txt.display_brightness}") 91 | brightness_label.set_halign(Gtk.Align.START) 92 | content_box.pack_start(brightness_label, False, True, 0) 93 | 94 | # Brightness control 95 | brightness_frame = Gtk.Frame() 96 | brightness_frame.set_shadow_type(Gtk.ShadowType.IN) 97 | brightness_frame.set_margin_top(5) 98 | brightness_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) 99 | brightness_box.set_margin_start(10) 100 | brightness_box.set_margin_end(10) 101 | brightness_box.set_margin_top(10) 102 | brightness_box.set_margin_bottom(10) 103 | 104 | # Brightness scale 105 | self.brightness_scale = Gtk.Scale.new_with_range( 106 | Gtk.Orientation.HORIZONTAL, 0, 100, 1 107 | ) 108 | self.brightness_scale.set_value(get_brightness(self.logging)) 109 | self.brightness_scale.set_value_pos(Gtk.PositionType.RIGHT) 110 | self.brightness_scale.connect("value-changed", self.on_brightness_changed) 111 | brightness_box.pack_start(self.brightness_scale, True, True, 0) 112 | 113 | # Quick brightness buttons 114 | brightness_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 115 | 116 | for value in [0, 25, 50, 75, 100]: 117 | button = Gtk.Button(label=f"{value}%") 118 | button.connect("clicked", self.on_brightness_button_clicked, value) 119 | brightness_buttons.pack_start(button, True, True, 0) 120 | 121 | brightness_box.pack_start(brightness_buttons, False, False, 0) 122 | brightness_frame.add(brightness_box) 123 | content_box.pack_start(brightness_frame, False, True, 0) 124 | 125 | # Blue light section 126 | bluelight_label = Gtk.Label() 127 | bluelight_label.set_markup(f"{self.txt.display_blue_light}") 128 | bluelight_label.set_halign(Gtk.Align.START) 129 | bluelight_label.set_margin_top(15) 130 | content_box.pack_start(bluelight_label, False, True, 0) 131 | 132 | # Blue light control 133 | bluelight_frame = Gtk.Frame() 134 | bluelight_frame.set_shadow_type(Gtk.ShadowType.IN) 135 | bluelight_frame.set_margin_top(5) 136 | bluelight_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) 137 | bluelight_box.set_margin_start(10) 138 | bluelight_box.set_margin_end(10) 139 | bluelight_box.set_margin_top(10) 140 | bluelight_box.set_margin_bottom(10) 141 | 142 | # Blue light scale 143 | self.bluelight_scale = Gtk.Scale.new_with_range( 144 | Gtk.Orientation.HORIZONTAL, 0, 100, 1 145 | ) 146 | settings = load_settings(self.logging) 147 | saved_gamma = settings.get("gamma", 6500) 148 | # Convert temperature to percentage 149 | percentage = (saved_gamma - 2500) / 40 # (6500-2500)/100 = 40 150 | self.bluelight_scale.set_value(percentage) 151 | self.bluelight_scale.set_value_pos(Gtk.PositionType.RIGHT) 152 | self.bluelight_scale.connect("value-changed", self.on_bluelight_changed) 153 | bluelight_box.pack_start(self.bluelight_scale, True, True, 0) 154 | 155 | # Quick blue light buttons 156 | bluelight_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 157 | 158 | # Map percentage to temperature values 159 | for value in [0, 25, 50, 75, 100]: 160 | button = Gtk.Button(label=f"{value}%") 161 | button.connect("clicked", self.on_bluelight_button_clicked, value) 162 | bluelight_buttons.pack_start(button, True, True, 0) 163 | 164 | bluelight_box.pack_start(bluelight_buttons, False, False, 0) 165 | bluelight_frame.add(bluelight_box) 166 | content_box.pack_start(bluelight_frame, False, True, 0) 167 | 168 | scroll_window.add(content_box) 169 | self.pack_start(scroll_window, True, True, 0) 170 | 171 | # Connect destroy signal to cleanup 172 | self.connect("destroy", self.on_destroy) 173 | 174 | # Connect visibility signals 175 | self.connect("map", self.on_mapped) 176 | self.connect("unmap", self.on_unmapped) 177 | 178 | # Only start auto-refresh when the tab becomes visible 179 | if self.get_mapped(): 180 | self.is_visible = True 181 | self.start_auto_update() 182 | 183 | self.previous_orientation = "normal" 184 | 185 | def on_display_changed(self, combo): 186 | self.current_display = combo.get_active_text() 187 | 188 | def on_brightness_changed(self, scale): 189 | """Handle brightness scale changes""" 190 | value = int(scale.get_value()) 191 | set_brightness(value, self.logging) 192 | 193 | def on_brightness_button_clicked(self, button, value): 194 | """Handle brightness button clicks""" 195 | self.brightness_scale.set_value(value) 196 | set_brightness(value, self.logging) 197 | 198 | def set_bluelight(self, temperature): 199 | """Set blue light level""" 200 | temperature = int(temperature) 201 | settings = load_settings(self.logging) 202 | settings["gamma"] = temperature 203 | save_settings(settings, self.logging) 204 | 205 | # Kill any existing gammastep process 206 | subprocess.run( 207 | ["pkill", "-f", "gammastep"], 208 | stdout=subprocess.DEVNULL, 209 | stderr=subprocess.DEVNULL, 210 | ) 211 | # Start new gammastep process with new temperature 212 | subprocess.Popen( 213 | ["gammastep", "-O", str(temperature)], 214 | stdout=subprocess.DEVNULL, 215 | stderr=subprocess.DEVNULL, 216 | ) 217 | 218 | def on_bluelight_changed(self, scale): 219 | """Handle blue light scale changes""" 220 | percentage = int(scale.get_value()) 221 | temperature = int(2500 + (percentage * 40)) 222 | self.set_bluelight(temperature) 223 | 224 | def on_bluelight_button_clicked(self, button, value): 225 | """Handle blue light button clicks""" 226 | self.bluelight_scale.set_value(value) 227 | # Convert percentage to temperature 228 | temperature = int(2500 + (value * 40)) 229 | self.set_bluelight(temperature) 230 | 231 | def refresh_display_settings(self, *args): 232 | """Refresh display settings""" 233 | self.logging.log(LogLevel.Info, "Refreshing display settings") 234 | 235 | # Block the value-changed signal before updating the brightness slider 236 | self.brightness_scale.disconnect_by_func(self.on_brightness_changed) 237 | 238 | # Update brightness slider with current value 239 | current_brightness = get_brightness(self.logging) 240 | self.brightness_scale.set_value(current_brightness) 241 | 242 | # Reconnect the value-changed signal 243 | self.brightness_scale.connect("value-changed", self.on_brightness_changed) 244 | 245 | # Reload settings from file 246 | settings = load_settings(self.logging) 247 | saved_gamma = settings.get("gamma", 6500) 248 | # Convert temperature to percentage (non-inverted: 2500K = 0%, 6500K = 100%) 249 | percentage = (saved_gamma - 2500) / 40 # (6500-2500)/100 = 40 250 | 251 | # Block the value-changed signal before updating the blue light filter slider 252 | self.bluelight_scale.disconnect_by_func(self.on_bluelight_changed) 253 | 254 | # Update blue light filter slider with saved value 255 | self.bluelight_scale.set_value(percentage) 256 | 257 | # Reconnect the value-changed signal 258 | self.bluelight_scale.connect("value-changed", self.on_bluelight_changed) 259 | 260 | # Return True to keep the timer running if this was called by the timer 261 | return True 262 | 263 | def on_mapped(self, widget): 264 | """Called when the widget becomes visible""" 265 | self.is_visible = True 266 | self.start_auto_update() 267 | self.logging.log(LogLevel.Info, "Display tab became visible, starting auto-update") 268 | 269 | def on_unmapped(self, widget): 270 | """Called when the widget is hidden""" 271 | self.is_visible = False 272 | self.stop_auto_update() 273 | self.logging.log(LogLevel.Info, "Display tab hidden, stopping auto-update") 274 | 275 | def start_auto_update(self): 276 | """Start auto-updating display settings""" 277 | if self.update_timeout_id is None and self.is_visible: 278 | self.update_timeout_id = GLib.timeout_add( 279 | self.update_interval, self.refresh_display_settings 280 | ) 281 | self.logging.log(LogLevel.Info, f"Auto-update started with {self.update_interval}ms interval") 282 | 283 | def stop_auto_update(self): 284 | """Stop auto-updating display settings""" 285 | if self.update_timeout_id is not None: 286 | GLib.source_remove(self.update_timeout_id) 287 | self.update_timeout_id = None 288 | self.logging.log(LogLevel.Info, "Auto-update stopped") 289 | 290 | def on_destroy(self, widget): 291 | """Clean up resources when widget is destroyed""" 292 | self.stop_auto_update() 293 | -------------------------------------------------------------------------------- /src/ui/tabs/settings_tab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import gi 4 | 5 | from utils.logger import LogLevel, Logger 6 | from utils.translations import English, Spanish, Portuguese, French # type: ignore 7 | 8 | gi.require_version("Gtk", "3.0") 9 | from gi.repository import Gtk, GObject # type: ignore 10 | 11 | from utils.settings import load_settings, save_settings 12 | 13 | 14 | class SettingsTab(Gtk.Box): 15 | """Tab for application settings""" 16 | __gsignals__ = { 17 | 'tab-visibility-changed': (GObject.SignalFlags.RUN_LAST, None, (str, bool,)), 18 | 'tab-order-changed': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), 19 | 'vertical-tabs-changed': (GObject.SignalFlags.RUN_LAST, None, (bool,)), 20 | 'vertical-tabs-icon-only-changed': (GObject.SignalFlags.RUN_LAST, None, (bool,)), 21 | } 22 | 23 | def __init__(self, logging: Logger, txt: English | Spanish | Portuguese | French): 24 | super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10) 25 | self.txt = txt 26 | self.logging = logging 27 | 28 | self.set_margin_top(10) 29 | self.set_margin_bottom(10) 30 | self.set_margin_start(10) 31 | self.set_margin_end(10) 32 | self.set_hexpand(True) 33 | self.set_vexpand(True) 34 | 35 | # Load settings 36 | self.settings = load_settings(logging) 37 | 38 | # Header with Settings title and icon 39 | header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) 40 | header_box.set_margin_bottom(10) 41 | header_box.set_halign(Gtk.Align.START) 42 | header_box.set_hexpand(True) 43 | 44 | settings_icon = Gtk.Image.new_from_icon_name( 45 | "preferences-system-symbolic", Gtk.IconSize.DIALOG 46 | ) 47 | ctx = settings_icon.get_style_context() 48 | ctx.add_class("settings-icon") 49 | 50 | def on_enter(widget, event): 51 | ctx.add_class("settings-icon-animate") 52 | 53 | def on_leave(widget, event): 54 | ctx.remove_class("settings-icon-animate") 55 | 56 | icon_event_box = Gtk.EventBox() 57 | icon_event_box.add(settings_icon) 58 | icon_event_box.connect("enter-notify-event", on_enter) 59 | icon_event_box.connect("leave-notify-event", on_leave) 60 | 61 | header_box.pack_start(icon_event_box, False, False, 0) 62 | 63 | settings_label = Gtk.Label(label=self.txt.settings_title) 64 | settings_label.set_markup(f"{self.txt.settings_title}") 65 | header_box.pack_start(settings_label, False, False, 0) 66 | 67 | self.pack_start(header_box, False, False, 0) 68 | 69 | # Wrap content in a scrolled window 70 | scrolled_window = Gtk.ScrolledWindow() 71 | scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) 72 | scrolled_window.set_hexpand(True) 73 | scrolled_window.set_vexpand(True) 74 | self.pack_start(scrolled_window, True, True, 0) 75 | 76 | # Create notebook for tabs 77 | self.notebook = Gtk.Notebook() 78 | self.notebook.set_hexpand(True) 79 | self.notebook.set_vexpand(True) 80 | scrolled_window.add(self.notebook) 81 | 82 | # Create Tabs Reordering tab 83 | self.create_tabs_reordering_tab() 84 | 85 | # Create Language tab 86 | self.create_language_tab() 87 | 88 | self.show_all() 89 | 90 | self.logging.log(LogLevel.Info, "Settings UI with tabs has been created") 91 | 92 | def create_tabs_reordering_tab(self): 93 | tab_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) 94 | tab_box.set_margin_top(10) 95 | tab_box.set_margin_bottom(10) 96 | tab_box.set_margin_start(10) 97 | tab_box.set_margin_end(10) 98 | tab_box.set_hexpand(True) 99 | tab_box.set_vexpand(True) 100 | 101 | # Frame for tab settings 102 | settings_frame = Gtk.Frame() 103 | settings_frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN) 104 | settings_frame.set_hexpand(True) 105 | settings_frame.set_vexpand(True) 106 | tab_box.pack_start(settings_frame, True, True, 0) 107 | 108 | self.tab_section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) 109 | self.tab_section.set_margin_top(10) 110 | self.tab_section.set_margin_bottom(10) 111 | self.tab_section.set_margin_start(10) 112 | self.tab_section.set_margin_end(10) 113 | self.tab_section.set_hexpand(True) 114 | self.tab_section.set_vexpand(True) 115 | settings_frame.add(self.tab_section) 116 | 117 | section_label = Gtk.Label(label=self.txt.settings_tab_settings) 118 | section_label.set_markup(f"{self.txt.settings_tab_settings}") 119 | section_label.set_halign(Gtk.Align.START) 120 | self.tab_section.pack_start(section_label, False, False, 0) 121 | 122 | tabs = ["Wi-Fi", "Volume", "Bluetooth", "Battery", "Display", "Power", "Autostart", "USBGuard"] 123 | self.tab_switches = {} 124 | self.tab_rows = {} 125 | 126 | for tab_name in tabs: 127 | row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 128 | row.set_hexpand(True) 129 | row.set_margin_top(2) 130 | row.set_margin_bottom(2) 131 | 132 | button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) 133 | button_box.set_valign(Gtk.Align.CENTER) 134 | 135 | up_button = Gtk.Button() 136 | up_button.set_image(Gtk.Image.new_from_icon_name("go-up-symbolic", Gtk.IconSize.BUTTON)) 137 | up_button.set_relief(Gtk.ReliefStyle.NONE) 138 | up_button.connect("clicked", self.on_move_up_clicked, tab_name) 139 | button_box.pack_start(up_button, False, False, 0) 140 | 141 | down_button = Gtk.Button() 142 | down_button.set_image(Gtk.Image.new_from_icon_name("go-down-symbolic", Gtk.IconSize.BUTTON)) 143 | down_button.set_relief(Gtk.ReliefStyle.NONE) 144 | down_button.connect("clicked", self.on_move_down_clicked, tab_name) 145 | button_box.pack_start(down_button, False, False, 0) 146 | 147 | up_button.set_size_request(24, 24) 148 | down_button.set_size_request(24, 24) 149 | 150 | row.pack_start(button_box, False, False, 0) 151 | 152 | label = Gtk.Label(label=f"{self.txt.show} {tab_name} tab") 153 | label.set_halign(Gtk.Align.START) 154 | row.pack_start(label, True, True, 0) 155 | 156 | switch = Gtk.Switch() 157 | visible = self.settings.get("visibility", {}).get(tab_name, True) 158 | switch.set_active(visible) 159 | switch.connect("notify::active", self.on_tab_visibility_changed, tab_name) 160 | switch.set_size_request(40, 20) 161 | switch.set_valign(Gtk.Align.CENTER) 162 | 163 | switch_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) 164 | switch_box.set_size_request(50, 24) 165 | switch_box.set_valign(Gtk.Align.CENTER) 166 | switch_box.pack_start(switch, True, False, 0) 167 | 168 | row.pack_end(switch_box, False, False, 0) 169 | 170 | self.tab_switches[tab_name] = switch 171 | self.tab_rows[tab_name] = row 172 | 173 | self.update_ui_order() 174 | 175 | # Add vertical tabs toggle 176 | vertical_tabs_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 177 | vertical_tabs_row.set_hexpand(True) 178 | vertical_tabs_row.set_margin_top(10) 179 | vertical_tabs_row.set_margin_bottom(10) 180 | 181 | vertical_tabs_label = Gtk.Label(label=self.txt.settings_vertical_tabs_label if hasattr(self.txt, 'settings_vertical_tabs_label') else "Enable Vertical Tabs") 182 | vertical_tabs_label.set_halign(Gtk.Align.START) 183 | vertical_tabs_row.pack_start(vertical_tabs_label, True, True, 0) 184 | 185 | self.vertical_tabs_switch = Gtk.Switch() 186 | vertical_tabs_enabled = self.settings.get("vertical_tabs", False) 187 | self.vertical_tabs_switch.set_active(vertical_tabs_enabled) 188 | self.vertical_tabs_switch.set_size_request(40, 20) 189 | self.vertical_tabs_switch.set_valign(Gtk.Align.CENTER) 190 | self.vertical_tabs_switch.connect("notify::active", self.on_vertical_tabs_toggled) 191 | 192 | vertical_tabs_switch_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) 193 | vertical_tabs_switch_box.set_size_request(50, 24) 194 | vertical_tabs_switch_box.set_valign(Gtk.Align.CENTER) 195 | vertical_tabs_switch_box.pack_start(self.vertical_tabs_switch, True, False, 0) 196 | 197 | vertical_tabs_row.pack_end(vertical_tabs_switch_box, False, False, 0) 198 | 199 | self.tab_section.pack_start(vertical_tabs_row, False, False, 0) 200 | 201 | # Add vertical tabs icon-only toggle 202 | vertical_tabs_icon_only_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 203 | vertical_tabs_icon_only_row.set_hexpand(True) 204 | vertical_tabs_icon_only_row.set_margin_top(10) 205 | vertical_tabs_icon_only_row.set_margin_bottom(10) 206 | 207 | vertical_tabs_icon_only_label = Gtk.Label(label="Show only icons in vertical tabs") 208 | vertical_tabs_icon_only_label.set_halign(Gtk.Align.START) 209 | vertical_tabs_icon_only_row.pack_start(vertical_tabs_icon_only_label, True, True, 0) 210 | 211 | self.vertical_tabs_icon_only_switch = Gtk.Switch() 212 | vertical_tabs_icon_only_enabled = self.settings.get("vertical_tabs_icon_only", False) 213 | self.vertical_tabs_icon_only_switch.set_active(vertical_tabs_icon_only_enabled) 214 | self.vertical_tabs_icon_only_switch.set_size_request(40, 20) 215 | self.vertical_tabs_icon_only_switch.set_valign(Gtk.Align.CENTER) 216 | self.vertical_tabs_icon_only_switch.connect("notify::active", self.on_vertical_tabs_icon_only_toggled) 217 | 218 | vertical_tabs_icon_only_switch_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) 219 | vertical_tabs_icon_only_switch_box.set_size_request(50, 24) 220 | vertical_tabs_icon_only_switch_box.set_valign(Gtk.Align.CENTER) 221 | vertical_tabs_icon_only_switch_box.pack_start(self.vertical_tabs_icon_only_switch, True, False, 0) 222 | 223 | vertical_tabs_icon_only_row.pack_end(vertical_tabs_icon_only_switch_box, False, False, 0) 224 | 225 | self.tab_section.pack_start(vertical_tabs_icon_only_row, False, False, 0) 226 | 227 | tab_icon = Gtk.Image.new_from_icon_name("preferences-system-symbolic", Gtk.IconSize.MENU) 228 | tab_text = Gtk.Label(label="Tab Settings") 229 | tab_label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 230 | tab_label_box.pack_start(tab_icon, False, False, 0) 231 | tab_label_box.pack_start(tab_text, False, False, 0) 232 | tab_label_box.show_all() 233 | self.notebook.append_page(tab_box, tab_label_box) 234 | 235 | def create_language_tab(self): 236 | lang_box_outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) 237 | lang_box_outer.set_margin_top(10) 238 | lang_box_outer.set_margin_bottom(10) 239 | lang_box_outer.set_margin_start(10) 240 | lang_box_outer.set_margin_end(10) 241 | lang_box_outer.set_hexpand(True) 242 | lang_box_outer.set_vexpand(True) 243 | 244 | lang_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 245 | lang_box.set_margin_top(10) 246 | lang_box.set_margin_bottom(10) 247 | 248 | lang_label = Gtk.Label(label=self.txt.settings_language) 249 | lang_label.set_halign(Gtk.Align.START) 250 | 251 | lang_combo = Gtk.ComboBoxText() 252 | lang_combo.append("default", "Default (System)") 253 | lang_combo.append("en", "English") 254 | lang_combo.append("es", "Español") 255 | lang_combo.append("pt", "Português") 256 | lang_combo.append("fr", "Français") 257 | lang_combo.append("id", "Bahasa Indonesia") 258 | lang_combo.append("it", "Italian") 259 | lang_combo.append("tr", "Turkish") 260 | lang_combo.append("de", "German") 261 | lang_combo.set_active_id(self.settings.get("language")) 262 | lang_combo.connect("changed", self.on_language_changed) 263 | 264 | lang_box.pack_start(lang_label, True, True, 0) 265 | lang_box.pack_end(lang_combo, False, False, 0) 266 | 267 | lang_box_outer.pack_start(lang_box, False, False, 0) 268 | 269 | lang_icon = Gtk.Image.new_from_icon_name("preferences-desktop-locale-symbolic", Gtk.IconSize.MENU) 270 | lang_text = Gtk.Label(label="Language") 271 | lang_label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 272 | lang_label_box.pack_start(lang_icon, False, False, 0) 273 | lang_label_box.pack_start(lang_text, False, False, 0) 274 | lang_label_box.show_all() 275 | self.notebook.append_page(lang_box_outer, lang_label_box) 276 | 277 | def update_ui_order(self): 278 | tab_order = self.settings.get("tab_order", ["Volume", "Wi-Fi", "Bluetooth", "Battery", "Display", "Power", "Autostart", "USBGuard"]) 279 | all_tabs = ["Volume", "Wi-Fi", "Bluetooth", "Battery", "Display", "Power", "Autostart", "USBGuard"] 280 | for tab in all_tabs: 281 | if tab not in tab_order: 282 | if tab == "Autostart": 283 | if "USBGuard" in tab_order: 284 | autostart_index = tab_order.index("USBGuard") 285 | tab_order.insert(autostart_index, tab) 286 | else: 287 | tab_order.append(tab) 288 | elif tab == "USBGuard": 289 | tab_order.append(tab) 290 | else: 291 | tab_order.append(tab) 292 | 293 | self.settings["tab_order"] = tab_order 294 | save_settings(self.settings, self.logging) 295 | 296 | for row in self.tab_section.get_children(): 297 | if isinstance(row, Gtk.Box) and row != self.tab_section.get_children()[0]: 298 | self.tab_section.remove(row) 299 | 300 | for tab_name in tab_order: 301 | if tab_name in self.tab_rows: 302 | self.tab_rows[tab_name].set_hexpand(True) 303 | self.tab_rows[tab_name].set_margin_top(4) 304 | self.tab_rows[tab_name].set_margin_bottom(4) 305 | self.tab_section.pack_start(self.tab_rows[tab_name], False, False, 2) 306 | 307 | def on_tab_visibility_changed(self, switch, gparam, tab_name): 308 | active = switch.get_active() 309 | if "visibility" not in self.settings: 310 | self.settings["visibility"] = {} 311 | self.settings["visibility"][tab_name] = active 312 | save_settings(self.settings, self.logging) 313 | self.emit("tab-visibility-changed", tab_name, active) 314 | 315 | def on_vertical_tabs_toggled(self, switch, gparam): 316 | active = switch.get_active() 317 | self.settings["vertical_tabs"] = active 318 | save_settings(self.settings, self.logging) 319 | # Emit a custom signal if needed to notify main window 320 | self.emit("vertical-tabs-changed", active) 321 | 322 | def on_vertical_tabs_icon_only_toggled(self, switch, gparam): 323 | active = switch.get_active() 324 | self.settings["vertical_tabs_icon_only"] = active 325 | save_settings(self.settings, self.logging) 326 | self.emit("vertical-tabs-icon-only-changed", active) 327 | 328 | def on_move_up_clicked(self, button, tab_name): 329 | tab_order = self.settings.get("tab_order", ["Volume", "Wi-Fi", "Bluetooth", "Battery", "Display", "Power", "Autostart", "USBGuard"]) 330 | current_index = tab_order.index(tab_name) 331 | if current_index > 0: 332 | tab_order[current_index], tab_order[current_index - 1] = tab_order[current_index - 1], tab_order[current_index] 333 | self.settings["tab_order"] = tab_order 334 | save_settings(self.settings, self.logging) 335 | self.update_ui_order() 336 | self.emit("tab-order-changed", tab_order) 337 | 338 | def on_move_down_clicked(self, button, tab_name): 339 | tab_order = self.settings.get("tab_order", ["Volume", "Wi-Fi", "Bluetooth", "Battery", "Display", "Power", "Autostart", "USBGuard"]) 340 | current_index = tab_order.index(tab_name) 341 | if current_index >= len(tab_order) - 1: 342 | return 343 | tab_order[current_index], tab_order[current_index + 1] = tab_order[current_index + 1], tab_order[current_index] 344 | self.settings["tab_order"] = tab_order 345 | save_settings(self.settings, self.logging) 346 | self.update_ui_order() 347 | self.emit("tab-order-changed", tab_order) 348 | 349 | def save_window_size(self, width: int, height: int): 350 | if "window_size" not in self.settings: 351 | self.settings["window_size"] = {} 352 | self.settings["window_size"]["settings_width"] = width 353 | self.settings["window_size"]["settings_height"] = height 354 | save_settings(self.settings, self.logging) 355 | self.logging.log(LogLevel.Info, f"Saved reference window size: {width}x{height}") 356 | 357 | def on_language_changed(self, combo): 358 | lang = combo.get_active_id() 359 | self.settings["language"] = lang 360 | self.logging.log(LogLevel.Info, f"Language changed to {lang}, saving settings") 361 | 362 | if "language" not in self.settings: 363 | self.logging.log(LogLevel.Warn, "Language key not in settings, adding it") 364 | 365 | save_settings(self.settings, self.logging) 366 | 367 | saved_settings = load_settings(self.logging) 368 | if saved_settings.get("language") == lang: 369 | self.logging.log(LogLevel.Info, f"Language setting verified: {saved_settings.get('language')}") 370 | else: 371 | self.logging.log(LogLevel.Error, f"Language setting not saved correctly. Expected: {lang}, Got: {saved_settings.get('language')}") 372 | self.logging.log(LogLevel.Info, "Forcing language setting in configuration file") 373 | saved_settings["language"] = lang 374 | save_settings(saved_settings, self.logging) 375 | 376 | parent_window = self.get_toplevel() 377 | if hasattr(parent_window, 'settings'): 378 | parent_window.settings["language"] = lang 379 | self.logging.log(LogLevel.Info, f"Updated main window's language setting to {lang}") 380 | 381 | dialog = Gtk.MessageDialog( 382 | transient_for=self.get_toplevel(), 383 | flags=0, 384 | message_type=Gtk.MessageType.INFO, 385 | buttons=Gtk.ButtonsType.OK, 386 | text=self.txt.settings_language_changed 387 | ) 388 | dialog.format_secondary_text( 389 | self.txt.settings_language_changed_restart 390 | ) 391 | dialog.run() 392 | dialog.destroy() 393 | -------------------------------------------------------------------------------- /src/ui/widgets/bluetooth_device_row.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import gi 4 | 5 | from utils.translations import English, Spanish # type: ignore 6 | 7 | gi.require_version("Gtk", "3.0") 8 | gi.require_version("Pango", "1.0") 9 | from gi.repository import Gtk, Pango # type: ignore 10 | 11 | class BluetoothDeviceRow(Gtk.ListBoxRow): 12 | def __init__(self, device, txt:English|Spanish): 13 | super().__init__() 14 | self.txt = txt 15 | self.set_margin_top(5) 16 | self.set_margin_bottom(5) 17 | self.set_margin_start(10) 18 | self.set_margin_end(10) 19 | 20 | # Store device info 21 | self.device_path = device['path'] 22 | self.mac_address = device['mac'] 23 | self.device_name = device['name'] 24 | self.is_connected = device['connected'] 25 | self.is_paired = device['paired'] 26 | self.device_type = device['icon'] if device['icon'] else 'unknown' 27 | self.battery_percentage = device.get('battery', None) 28 | 29 | # Main container for the row 30 | container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) 31 | self.add(container) 32 | 33 | # Device icon based on type 34 | device_icon = Gtk.Image.new_from_icon_name(self.get_icon_name_for_device(), Gtk.IconSize.LARGE_TOOLBAR) 35 | container.pack_start(device_icon, False, False, 0) 36 | 37 | # Left side with device name and type 38 | left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) 39 | 40 | name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 41 | name_label = Gtk.Label(label=self.device_name) 42 | name_label.set_halign(Gtk.Align.START) 43 | name_label.set_ellipsize(Pango.EllipsizeMode.END) 44 | name_label.set_max_width_chars(20) 45 | if self.is_connected: 46 | name_label.set_markup(f"{self.device_name}") 47 | name_status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) 48 | name_status_box.pack_start(name_label, False, True, 0) 49 | 50 | if self.is_connected: 51 | status_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=3) 52 | status_container.get_style_context().add_class("device-status-container") 53 | 54 | # Connection indicator 55 | connection_indicator = Gtk.DrawingArea() 56 | connection_indicator.set_size_request(12, 12) 57 | connection_indicator.get_style_context().add_class("connection-indicator") 58 | connection_indicator.get_style_context().add_class("connected") 59 | status_container.pack_start(connection_indicator, False, False, 0) 60 | 61 | status_text = self.txt.connected 62 | if self.battery_percentage is not None: 63 | status_text += f" • {self.battery_percentage}%" 64 | status_label = Gtk.Label(label=status_text) 65 | status_label.get_style_context().add_class("status-label") 66 | status_container.pack_start(status_label, False, False, 0) 67 | 68 | name_status_box.pack_start(status_container, False, False, 4) 69 | 70 | name_box.pack_start(name_status_box, True, True, 0) 71 | 72 | left_box.pack_start(name_box, False, False, 0) 73 | 74 | # Device details box 75 | details_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 76 | 77 | type_label = Gtk.Label(label=self.get_friendly_device_type()) 78 | type_label.set_halign(Gtk.Align.START) 79 | type_label.get_style_context().add_class("dim-label") 80 | details_box.pack_start(type_label, False, False, 0) 81 | 82 | mac_label = Gtk.Label(label=self.mac_address) 83 | mac_label.set_halign(Gtk.Align.START) 84 | mac_label.get_style_context().add_class("dim-label") 85 | details_box.pack_start(mac_label, False, False, 10) 86 | 87 | left_box.pack_start(details_box, False, False, 0) 88 | 89 | container.pack_start(left_box, True, True, 0) 90 | 91 | # Add connect/disconnect buttons 92 | button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 93 | 94 | self.connect_button = Gtk.Button(label=self.txt.connect) 95 | self.connect_button.set_valign(Gtk.Align.CENTER) 96 | self.connect_button.set_sensitive(not self.is_connected) 97 | button_box.pack_end(self.connect_button, False, False, 0) 98 | 99 | self.disconnect_button = Gtk.Button(label=self.txt.disconnect) 100 | self.disconnect_button.set_valign(Gtk.Align.CENTER) 101 | self.disconnect_button.set_sensitive(self.is_connected) 102 | button_box.pack_end(self.disconnect_button, False, False, 0) 103 | 104 | # Adde forget button 105 | self.forget_button = Gtk.Button(label=self.txt.forget) 106 | self.forget_button.set_valign(Gtk.Align.CENTER) 107 | self.forget_button.set_sensitive(True) 108 | button_box.pack_end(self.forget_button, False, False, 0) 109 | 110 | container.pack_end(button_box, False, False, 0) 111 | 112 | def get_icon_name_for_device(self): 113 | """Return appropriate icon based on device type""" 114 | if self.device_type == "audio-headset" or self.device_type == "audio-headphones": 115 | return "audio-headset-symbolic" 116 | elif self.device_type == "audio-card": 117 | return "audio-speakers-symbolic" 118 | elif self.device_type == "input-keyboard": 119 | return "input-keyboard-symbolic" 120 | elif self.device_type == "input-mouse": 121 | return "input-mouse-symbolic" 122 | elif self.device_type == "input-gaming": 123 | return "input-gaming-symbolic" 124 | elif self.device_type == "phone": 125 | return "phone-symbolic" 126 | else: 127 | return "bluetooth-symbolic" 128 | 129 | def get_friendly_device_type(self): 130 | """Return user-friendly device type name""" 131 | type_names = { 132 | "audio-headset": "Headset", 133 | "audio-headphones": "Headphones", 134 | "audio-card": "Speaker", 135 | "input-keyboard": "Keyboard", 136 | "input-mouse": "Mouse", 137 | "input-gaming": "Game Controller", 138 | "phone": "Phone", 139 | "unknown": "Device", 140 | } 141 | return type_names.get(self.device_type, "Bluetooth Device") 142 | 143 | def get_battery_level_icon(self): 144 | """Return battery level icon suffix based on percentage""" 145 | if not self.battery_percentage: 146 | return "missing" 147 | if self.battery_percentage >= 90: 148 | return "100" 149 | elif self.battery_percentage >= 70: 150 | return "080" 151 | elif self.battery_percentage >= 50: 152 | return "060" 153 | elif self.battery_percentage >= 30: 154 | return "040" 155 | elif self.battery_percentage >= 10: 156 | return "020" 157 | else: 158 | return "000" 159 | 160 | def get_mac_address(self): 161 | return self.mac_address 162 | 163 | def get_device_name(self): 164 | return self.device_name 165 | 166 | def get_is_connected(self): 167 | return self.is_connected 168 | -------------------------------------------------------------------------------- /src/ui/widgets/wifi_network_row.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | import gi # type: ignore 5 | from utils.logger import LogLevel, Logger 6 | from pathlib import Path 7 | from tools.wifi import generate_wifi_qrcode, get_connection_info 8 | 9 | gi.require_version("Gtk", "3.0") 10 | from gi.repository import Gtk # type: ignore 11 | 12 | 13 | class QRCodeDialog(Gtk.Dialog): 14 | def __init__(self, parent, qr_code_path): 15 | super().__init__(title="WiFi QR Code", transient_for=parent, flags=0) 16 | self.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK) 17 | 18 | box = self.get_content_area() 19 | box.set_spacing(10) 20 | 21 | if Path(qr_code_path).name == "error.png": 22 | error_label = Gtk.Label(label="Failed to generate QR code") 23 | box.add(error_label) 24 | else: 25 | image = Gtk.Image.new_from_file(qr_code_path) 26 | box.add(image) 27 | 28 | self.show_all() 29 | 30 | 31 | class WiFiNetworkRow(Gtk.ListBoxRow): 32 | def __init__(self, network_info, logging: Logger, parent_window=None): 33 | super().__init__() 34 | self.set_margin_top(5) 35 | self.set_margin_bottom(5) 36 | self.set_margin_start(10) 37 | self.set_margin_end(10) 38 | self.parent_window = parent_window 39 | self.logging = logging 40 | 41 | parts = network_info.split() 42 | self.is_connected = "*" in parts[0] 43 | 44 | self.ssid = self._extract_ssid(parts, logging) 45 | self.security = self._extract_security(network_info) 46 | self.qr_button = Gtk.Button.new_from_icon_name("insert-image-symbolic", Gtk.IconSize.BUTTON) 47 | self.qr_button.connect("clicked", self._on_qr_button_clicked) 48 | self.qr_button.set_tooltip_text("Generate QR Code") 49 | signal_value = self._extract_signal(parts, logging) 50 | self.signal_strength = f"{signal_value}%" if signal_value > 0 else "0%" 51 | 52 | icon_name = self._determine_signal_icon(signal_value) 53 | security_icon = self._determine_security_icon() 54 | 55 | container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) 56 | self.add(container) 57 | 58 | wifi_icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.LARGE_TOOLBAR) 59 | container.pack_start(wifi_icon, False, False, 0) 60 | 61 | left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) 62 | 63 | ssid_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 64 | ssid_label = Gtk.Label(label=self.ssid) 65 | ssid_label.set_halign(Gtk.Align.START) 66 | if self.is_connected: 67 | ssid_label.set_markup(f"{self.ssid}") 68 | ssid_box.pack_start(ssid_label, True, True, 0) 69 | 70 | if self.is_connected: 71 | connected_label = Gtk.Label(label=" (Connected)") 72 | connected_label.get_style_context().add_class("success-label") 73 | ssid_box.pack_start(connected_label, False, False, 0) 74 | 75 | left_box.pack_start(ssid_box, False, False, 0) 76 | 77 | details_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) 78 | 79 | security_image = Gtk.Image.new_from_icon_name( 80 | security_icon, Gtk.IconSize.SMALL_TOOLBAR 81 | ) 82 | details_box.pack_start(security_image, False, False, 0) 83 | 84 | security_label = Gtk.Label(label=self.security) 85 | security_label.set_halign(Gtk.Align.START) 86 | security_label.get_style_context().add_class("dim-label") 87 | details_box.pack_start(security_label, False, False, 0) 88 | 89 | left_box.pack_start(details_box, False, False, 0) 90 | 91 | container.pack_start(left_box, True, True, 0) 92 | 93 | signal_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) 94 | signal_box.set_halign(Gtk.Align.END) 95 | 96 | signal_label = Gtk.Label(label=self.signal_strength) 97 | signal_box.pack_start(signal_label, False, False, 0) 98 | 99 | if self.is_connected: 100 | container.pack_end(self.qr_button, False, False, 0) 101 | container.pack_end(signal_box, False, False, 0) 102 | 103 | self.original_network_info = network_info 104 | 105 | def _on_qr_button_clicked(self, button): 106 | """Handle QR code button click""" 107 | if not self.is_connected: 108 | return 109 | 110 | conn_info = get_connection_info(self.ssid, self.logging) 111 | password = conn_info.get("password", "") 112 | security = conn_info.get("802-11-wireless-security.key-mgmt", "none").upper() 113 | 114 | qr_path = generate_wifi_qrcode(self.ssid, password, security, self.logging) 115 | QRCodeDialog(self.parent_window, qr_path) 116 | 117 | def _extract_ssid(self, parts, logging): 118 | if len(parts) <= 1: 119 | return "Unknown" 120 | 121 | if self.is_connected: 122 | try: 123 | active_connections = subprocess.getoutput( 124 | "nmcli -t -f NAME,DEVICE connection show --active" 125 | ).split("\n") 126 | for conn in active_connections: 127 | if ":" in conn: 128 | name = conn.split(":")[0] 129 | conn_type = subprocess.getoutput( 130 | f"nmcli -t -f TYPE connection show '{name}'" 131 | ) 132 | if "wifi" in conn_type: 133 | return name 134 | return parts[1] 135 | except Exception as e: 136 | logging.log( 137 | LogLevel.Error, f"Error getting active connection name: {e}" 138 | ) 139 | return parts[1] 140 | else: 141 | return parts[1] 142 | 143 | def _extract_security(self, network_info): 144 | if "WPA2" in network_info: 145 | return "WPA2" 146 | elif "WPA3" in network_info: 147 | return "WPA3" 148 | elif "WPA" in network_info: 149 | return "WPA" 150 | elif "WEP" in network_info: 151 | return "WEP" 152 | else: 153 | return "Open" 154 | 155 | def _extract_signal(self, parts, logging): 156 | signal_value = 0 157 | try: 158 | if len(parts) > 6 and parts[6].isdigit(): 159 | signal_value = int(parts[6]) 160 | else: 161 | for i, p in enumerate(parts): 162 | if p.isdigit() and 0 <= int(p) <= 100 and i != 4: 163 | signal_value = int(p) 164 | break 165 | except (IndexError, ValueError) as e: 166 | logging.log(LogLevel.Error, f"Error parsing signal strength from {parts}: {e}") 167 | signal_value = 0 168 | return signal_value 169 | 170 | def _determine_signal_icon(self, signal_value): 171 | if signal_value >= 80: 172 | return "network-wireless-signal-excellent-symbolic" 173 | elif signal_value >= 60: 174 | return "network-wireless-signal-good-symbolic" 175 | elif signal_value >= 40: 176 | return "network-wireless-signal-ok-symbolic" 177 | elif signal_value > 0: 178 | return "network-wireless-signal-weak-symbolic" 179 | else: 180 | return "network-wireless-signal-none-symbolic" 181 | 182 | def _determine_security_icon(self): 183 | return "network-wireless-encrypted-symbolic" if self.security != "Open" else "network-wireless-symbolic" 184 | 185 | def get_ssid(self): 186 | return self.ssid 187 | 188 | def get_security(self): 189 | return self.security 190 | 191 | def get_original_network_info(self): 192 | return self.original_network_info 193 | 194 | def is_secured(self): 195 | return self.security != "Open" 196 | -------------------------------------------------------------------------------- /src/utils/arg_parser.py: -------------------------------------------------------------------------------- 1 | from sys import stderr, stdout 2 | from typing import Dict, List, Optional, TextIO, Tuple 3 | 4 | from tools.terminal import term_support_color 5 | 6 | 7 | def sprint(file: Optional[TextIO], *args) -> None: 8 | """a 'print' statement macro with the 'file' parameter placed on the beggining 9 | 10 | Args: 11 | file (Optional[TextIO]): the file stream to output to 12 | """ 13 | if file is None: 14 | file = stdout 15 | print(*args, file=file, flush=True) 16 | 17 | 18 | class ArgParse: 19 | def __init__(self, args: List[str]) -> None: 20 | self.__bin: str = "" 21 | self.__args: Dict[str, List[str | Dict[str, str]]] = {"long": [], "short": []} 22 | previous_arg_type: str = "" 23 | 24 | # ? This makes sure that detecting -ai as -a -i works 25 | # ? and also makes it not dependant on a lot of string operations 26 | for i, arg in enumerate(args): 27 | if i == 0: 28 | self.__bin = arg 29 | 30 | elif arg.startswith("--"): 31 | self.__args["long"].append(arg[2:]) 32 | previous_arg_type = "long" 33 | 34 | elif arg.startswith("-"): 35 | self.__args["short"].append(arg[1:]) 36 | previous_arg_type = "short" 37 | 38 | # ? If an arg reaches this statement, 39 | # ? that means the arg doesnt have a prefix of '-'. 40 | # ? Which would mean that it is an option 41 | elif previous_arg_type == "short": 42 | self.__args["short"].append({"option": arg}) 43 | elif previous_arg_type == "long": 44 | self.__args["long"].append({"option": arg}) 45 | 46 | def find_arg(self, __arg: Tuple[str, str]) -> bool: 47 | """tries to find 'arg' inside the argument list 48 | 49 | Args: 50 | arg (Tuple[str, str]): a tuple containing a short form of the arg, and long form 51 | 52 | Returns: 53 | bool: true if one of the arg is found, or false 54 | """ 55 | # ? Check for short arg 56 | for arg in self.__args["short"]: 57 | if not isinstance(arg, Dict) and __arg[0][1:] in arg: 58 | return True 59 | 60 | # ? Check for long arg 61 | for arg in self.__args["long"]: 62 | if not isinstance(arg, Dict) and __arg[1][2:] == arg: 63 | return True 64 | return False 65 | 66 | def option_arg(self, __arg: Tuple[str, str]) -> Optional[str]: 67 | """tries to find and return an option from a given argument 68 | 69 | Args: 70 | arg (Tuple[str, str]): a tuple containing a short form of the arg, and long form 71 | 72 | Returns: 73 | Optional[str]: the option given or None 74 | """ 75 | # ? Check for short arg 76 | for i, arg in enumerate(self.__args["short"]): 77 | if isinstance(arg, Dict): 78 | continue 79 | 80 | # ? Checks for equal signs, which allow us to check for -lo=a and -o=a 81 | if "=" in arg: 82 | eq_index: int = arg.index("=") 83 | split_arg: Tuple[str, str] = (arg[:eq_index], arg[eq_index + 1 :]) 84 | 85 | # ? Check for -lo=a and -o=a 86 | if (len(split_arg[0]) > 1 and split_arg[0][-1] == __arg[0][1:]) or ( 87 | len(split_arg[0]) == 1 and split_arg[0][0] == __arg[0][1:] 88 | ): 89 | return split_arg[1] 90 | 91 | # ? Check for -oa 92 | if len(arg) > 1 and arg[0] == __arg[0][1:]: 93 | return arg[1:] 94 | 95 | # ? Check for -lo a 96 | if len(arg) > 1: 97 | for _, c in enumerate(arg): 98 | if c == __arg[0][1:] and i + 1 < len(self.__args["short"]): 99 | next_arg = self.__args["short"][i + 1] 100 | if isinstance(next_arg, Dict): 101 | return next_arg["option"] 102 | 103 | # ? Check for -o a 104 | if arg == __arg[0][1:] and i + 1 < len(self.__args["short"]): 105 | next_arg = self.__args["short"][i + 1] 106 | 107 | if isinstance(next_arg, Dict): 108 | return next_arg["option"] 109 | 110 | # ? Check for long arg 111 | for i, arg in enumerate(self.__args["long"]): 112 | if isinstance(arg, Dict): 113 | continue 114 | 115 | if "=" in arg: 116 | eq_index: int = arg.index("=") 117 | split_arg: Tuple[str, str] = (arg[:eq_index], arg[eq_index + 1 :]) 118 | 119 | # ? Check for --option=a 120 | if split_arg[0] == __arg[1][2:]: 121 | return split_arg[1] 122 | 123 | # ? Check for --option a 124 | if arg == __arg[1][2:] and i + 1 < len(self.__args["long"]): 125 | next_arg = self.__args["long"][i + 1] 126 | 127 | if isinstance(next_arg, Dict): 128 | return next_arg["option"] 129 | return None 130 | 131 | def arg_print(self, msg: str) -> None: 132 | sprint(self.__help_stream, msg) 133 | 134 | def print_help_msg(self, stream: Optional[TextIO]): 135 | self.__help_stream = stream 136 | use_colors = term_support_color() 137 | 138 | # Define colors if terminal supports them 139 | if use_colors: 140 | # Color definitions 141 | RESET = "\033[0m" 142 | BOLD = "\033[1m" 143 | UNDERLINE = "\033[4m" 144 | CYAN = "\033[36m" 145 | GREEN = "\033[32m" 146 | YELLOW = "\033[33m" 147 | BLUE = "\033[34m" 148 | MAGENTA = "\033[35m" 149 | GRAY = "\033[90m" 150 | WHITE = "\033[97m" 151 | else: 152 | # No colors if terminal doesn't support them 153 | RESET = BOLD = UNDERLINE = CYAN = GREEN = YELLOW = BLUE = MAGENTA = GRAY = WHITE = "" 154 | 155 | # Header 156 | self.arg_print(f"\n{BOLD}{CYAN}╭────────────────────────────────────────────────────────────────╮{RESET}") 157 | self.arg_print(f"{BOLD}{CYAN}│ BETTER CONTROL │{RESET}") 158 | self.arg_print(f"{BOLD}{CYAN}╰────────────────────────────────────────────────────────────────╯{RESET}\n") 159 | 160 | # Usage 161 | self.arg_print(f"{BOLD}USAGE:{RESET}") 162 | self.arg_print(f" {WHITE}better-control {GRAY}[options]{RESET} \n {WHITE}control {GRAY}[options]\n") 163 | 164 | # Options header 165 | self.arg_print(f"{BOLD}OPTIONS:{RESET}") 166 | 167 | # General options 168 | self.arg_print(f"{BOLD}{UNDERLINE}General:{RESET}") 169 | self.arg_print(f" {GREEN}-h, --help{RESET} Prints this help message") 170 | self.arg_print(f" {GREEN}-f, --force{RESET} Makes the app force to have all dependencies installed") 171 | self.arg_print(f" {GREEN}-s, --size{RESET} {YELLOW}{RESET} Sets a custom window size") 172 | self.arg_print(f" {GREEN}-L, --lang{RESET} Sets the language of the app (en,es,pt)") 173 | self.arg_print(f" {GREEN}-m, --minimal{RESET} Hides the notebook tabs and only shows the selected tab content\n") 174 | 175 | # Tab selection options 176 | self.arg_print(f"{BOLD}{UNDERLINE}Tab Selection:{RESET}") 177 | self.arg_print(f" {BLUE}-a, --autostart{RESET} Starts with the autostart tab open") 178 | self.arg_print(f" {BLUE}-B, --battery{RESET} Starts with the battery tab open") 179 | self.arg_print(f" {BLUE}-b, --bluetooth{RESET} Starts with the bluetooth tab open") 180 | self.arg_print(f" {BLUE}-d, --display{RESET} Starts with the display tab open") 181 | self.arg_print(f" {BLUE}-p, --power{RESET} Starts with the power tab open") 182 | self.arg_print(f" {BLUE}-u, --usbguard{RESET} Starts with the usbguard tab open") 183 | self.arg_print(f" {BLUE}-V, --volume{RESET} Starts with the volume tab open") 184 | self.arg_print(f" {BLUE}-v{RESET} Also starts with the volume tab open") 185 | self.arg_print(f" {BLUE}-w, --wifi{RESET} Starts with the wifi tab open\n") 186 | 187 | # Logging options 188 | self.arg_print(f"{BOLD}{UNDERLINE}Logging:{RESET}") 189 | self.arg_print(f" {MAGENTA}-l, --log{RESET} {YELLOW}{RESET} The program will either log to a file if given a file path,") 190 | self.arg_print(" or output to stdout based on the log level if given") 191 | self.arg_print(" a value between 0 and 3.") 192 | self.arg_print(f" {MAGENTA}-r, --redact{RESET} Redact sensitive information from logs\n") 193 | 194 | # Footer 195 | self.arg_print(f"{BOLD}{CYAN}╭────────────────────────────────────────────────────────────────╮{RESET}") 196 | self.arg_print(f"{BOLD}{CYAN}│ https://github.com/quantumvoid0/better-control │{RESET}") 197 | self.arg_print(f"{BOLD}{CYAN}╰────────────────────────────────────────────────────────────────╯{RESET}\n") 198 | 199 | if stream == stderr: 200 | exit(1) 201 | else: 202 | exit(0) 203 | -------------------------------------------------------------------------------- /src/utils/dependencies.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional 3 | from utils.logger import LogLevel, Logger 4 | 5 | DEPENDENCIES = [ 6 | ( 7 | "powerprofilesctl", 8 | "Power Profiles Control", 9 | "- Debian/Ubuntu: sudo apt install power-profiles-daemon\n- Arch Linux: sudo pacman -S power-profiles-daemon\n- Fedora: sudo dnf install power-profiles-daemon", 10 | ), 11 | ( 12 | "nmcli", 13 | "Network Manager CLI", 14 | "- Install NetworkManager package for your distro", 15 | ), 16 | ( 17 | "bluetoothctl", 18 | "Bluetooth Control", 19 | "- Debian/Ubuntu: sudo apt install bluez\n- Arch Linux: sudo pacman -S bluez bluez-utils\n- Fedora: sudo dnf install bluez", 20 | ), 21 | ( 22 | "pactl", 23 | "PulseAudio Control", 24 | "- Install PulseAudio or PipeWire depending on your distro", 25 | ), 26 | ( 27 | "brightnessctl", 28 | "Brightness Control", 29 | "- Debian/Ubuntu: sudo apt install brightnessctl\n- Arch Linux: sudo pacman -S brightnessctl\n- Fedora: sudo dnf install brightnessctl", 30 | ), 31 | ( 32 | "gammastep", 33 | "Blue Light Filter", 34 | "- Debian/Ubuntu: sudo apt install gammastep\n- Arch Linux: sudo pacman -S gammastep\n- Fedora: sudo dnf install gammastep", 35 | ), 36 | ( 37 | "upower", 38 | "Battery Information", 39 | "- Debian/Ubuntu: sudo apt install upower\n- Arch Linux: sudo pacman -S upower\n- Fedora: sudo dnf install upower" 40 | ) 41 | ] 42 | 43 | 44 | def check_dependency( 45 | command: str, name: str, install_instructions: str, logging: Logger 46 | ) -> Optional[str]: 47 | """Checks if a dependency is installed or not. 48 | 49 | Args: 50 | command (str): command used to check for the dependency 51 | name (str): the package name of the dependency 52 | install_instructions (str): the instruction used to install the package 53 | 54 | Returns: 55 | Optional[str]: Error message if dependency is missing, None otherwise 56 | """ 57 | if not shutil.which(command): 58 | error_msg = f"{name} is required but not installed!\n\nInstall it using:\n{install_instructions}" 59 | logging.log(LogLevel.Error, error_msg) 60 | return error_msg 61 | return None 62 | 63 | 64 | def check_all_dependencies(logging: Logger) -> bool: 65 | """Checks if all dependencies exist or not. 66 | 67 | Returns: 68 | bool: returns false if there are dependencies missing, or return true 69 | """ 70 | missing = [ 71 | check_dependency(cmd, name, inst, logging) for cmd, name, inst in DEPENDENCIES 72 | ] 73 | missing = [msg for msg in missing if msg] 74 | return not missing 75 | -------------------------------------------------------------------------------- /src/utils/hidden_devices.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | 5 | CONFIG_DIR = os.path.join( 6 | os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), 7 | "better-control" 8 | ) 9 | 10 | HIDDEN_DEVICES_FILE = os.path.join(CONFIG_DIR, "hidden_devices.json") 11 | PERMANENT_DEVICES_FILE = os.path.join(CONFIG_DIR, "permanent_devices.json") 12 | 13 | class DeviceStorage: 14 | """Base class for device storage""" 15 | def __init__(self, storage_file, logging): 16 | self.storage_file = storage_file 17 | self.logging = logging 18 | self.devices = set() 19 | self._ensure_config_dir() 20 | self.load() 21 | 22 | def _ensure_config_dir(self): 23 | """Ensure config directory exists""" 24 | try: 25 | os.makedirs(CONFIG_DIR, exist_ok=True) 26 | except Exception as e: 27 | self.logging.log_error(f"Error creating config dir: {e}") 28 | 29 | def load(self) -> bool: 30 | """Load devices from file""" 31 | try: 32 | if os.path.exists(self.storage_file): 33 | with open(self.storage_file, 'r') as f: 34 | data = json.load(f) 35 | if isinstance(data, list): 36 | self.devices = set(data) 37 | return True 38 | return False 39 | except Exception as e: 40 | self.logging.log_error(f"Error loading devices: {e}") 41 | return False 42 | 43 | def save(self) -> bool: 44 | """Save devices to file atomically""" 45 | try: 46 | temp_path = tempfile.mktemp(dir=CONFIG_DIR) 47 | with open(temp_path, 'w') as f: 48 | json.dump(list(self.devices), f) 49 | 50 | # Verify the file is valid 51 | with open(temp_path, 'r') as f: 52 | json.load(f) 53 | 54 | # Atomic replace 55 | os.replace(temp_path, self.storage_file) 56 | return True 57 | except Exception as e: 58 | self.logging.log_error(f"Error saving devices: {e}") 59 | try: 60 | if os.path.exists(temp_path): 61 | os.unlink(temp_path) 62 | except: 63 | pass 64 | return False 65 | 66 | def add(self, device_id: str) -> bool: 67 | """Add a device""" 68 | self.devices.add(device_id) 69 | return self.save() 70 | 71 | def remove(self, device_id: str) -> bool: 72 | """Remove a device""" 73 | self.devices.discard(device_id) 74 | return self.save() 75 | 76 | def contains(self, device_id: str) -> bool: 77 | """Check if device exists""" 78 | return device_id in self.devices 79 | 80 | def __iter__(self): 81 | """Allow iteration over device IDs""" 82 | return iter(self.devices) 83 | 84 | class HiddenDevices(DeviceStorage): 85 | """Class for managing hidden USB devices""" 86 | def __init__(self, logging): 87 | super().__init__(HIDDEN_DEVICES_FILE, logging) 88 | 89 | class PermanentDevices(DeviceStorage): 90 | """Class for managing permanently allowed USB devices""" 91 | def __init__(self, logging): 92 | super().__init__(PERMANENT_DEVICES_FILE, logging) 93 | 94 | 95 | def add(self, device_id: str) -> bool: 96 | """Add a device to hidden set""" 97 | self.devices.add(device_id) 98 | return self.save() 99 | 100 | def remove(self, device_id: str) -> bool: 101 | """Remove a device from hidden set""" 102 | self.devices.discard(device_id) 103 | return self.save() 104 | 105 | def contains(self, device_id: str) -> bool: 106 | """Check if device is hidden""" 107 | return device_id in self.devices 108 | 109 | def __iter__(self): 110 | """Allow iteration over hidden device IDs""" 111 | return iter(self.devices) 112 | -------------------------------------------------------------------------------- /src/utils/logger.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from enum import Enum 3 | import os 4 | import re 5 | import sys 6 | from sys import stderr, stdout 7 | import time 8 | from typing import Dict, Optional 9 | 10 | CRASH_LOG_DIR = os.path.join( 11 | os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), 12 | "better-control", 13 | "crashes" 14 | ) 15 | 16 | def emergency_log(message: str, stack: str = "") -> None: 17 | """Emergency logging for crashes""" 18 | try: 19 | os.makedirs(CRASH_LOG_DIR, exist_ok=True) 20 | crash_file = os.path.join( 21 | CRASH_LOG_DIR, 22 | f"crash_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.log" 23 | ) 24 | with open(crash_file, "w") as f: 25 | f.write(f"CRASH: {message}\n") 26 | f.write(f"Python: {sys.version}\n") 27 | f.write(f"Stack:\n{stack}\n") 28 | except: 29 | pass 30 | 31 | from utils.arg_parser import ArgParse 32 | from utils.pair import Pair 33 | from tools.terminal import term_support_color 34 | 35 | 36 | class LogLevel(Enum): 37 | Debug = 3 38 | Info = 2 39 | Warn = 1 40 | Error = 0 41 | 42 | 43 | def get_current_time(): 44 | now = datetime.datetime.now() 45 | ms = int((time.time() * 1000) % 1000) 46 | return f"{now.minute:02}:{now.second:02}:{ms:03}" 47 | 48 | 49 | class Logger: 50 | def __init__(self, arg_parser: ArgParse) -> None: 51 | log_info: Pair[bool, Optional[str]] = Pair(False, None) 52 | 53 | if arg_parser.find_arg(("-l", "--log")): 54 | log_info.first = True 55 | log_info.second = arg_parser.option_arg(("-l", "--log")) 56 | 57 | # Check if redaction is enabled via --redact flag 58 | self.__should_redact: bool = arg_parser.find_arg(("-r", "--redact")) 59 | 60 | self.__should_log: bool = log_info.first 61 | self.__log_level: int = ( 62 | int(log_info.second) 63 | if (log_info.second is not None) and (log_info.second.isdigit()) 64 | else 3 65 | ) 66 | self.__add_color: bool = term_support_color() 67 | self.__log_file_name: str = ( 68 | log_info.second 69 | if (log_info.second is not None) and (not log_info.second.isdigit()) 70 | else "" 71 | ) 72 | self.__labels: Dict[LogLevel, Pair[str, str]] = { 73 | LogLevel.Info: Pair( 74 | "\x1b[1;37m[\x1b[1;32mINFO\x1b[1;37m]:\x1b[0;0;0m", "[INFO]:" 75 | ), 76 | LogLevel.Error: Pair( 77 | "\x1b[1;37m[\x1b[1;31mERROR\x1b[1;37m]:\x1b[0;0;0m", "[ERROR]:" 78 | ), 79 | LogLevel.Debug: Pair( 80 | "\x1b[1;37m[\x1b[1;36mDEBUG\x1b[1;37m]:\x1b[0;0;0m", "[DEBUG]:" 81 | ), 82 | LogLevel.Warn: Pair( 83 | "\x1b[1:37m[\x1b[1;33mWARNING\x1b[1;37m]:\x1b[0;0;0m", "[WARNING]:" 84 | ), 85 | } 86 | 87 | # Define patterns for sensitive information to redact 88 | self.__redaction_patterns = [ 89 | # WiFi network names/SSIDs 90 | (r'(Connecting to WiFi network: )([^\s]+)', r'\1[REDACTED-WIFI]'), 91 | (r'(Connected to )([^\s]+)( using saved connection)', r'\1[REDACTED-WIFI]\3'), 92 | 93 | # Device identifiers and names (audio, bluetooth, etc.) 94 | (r'(Current active output sink: )(.*)', r'\1[REDACTED-DEVICE]'), 95 | (r'(Current active input source: )(.*)', r'\1[REDACTED-DEVICE]'), 96 | (r'(Adding output sink: )([^\(]+)(\(.*\))', r'\1[REDACTED-DEVICE-ID] \3'), 97 | (r'(Adding input source: )([^\(]+)(\(.*\))', r'\1[REDACTED-DEVICE-ID] \3'), 98 | 99 | # User and machine identifiers 100 | (r'(application\.process\.user = ")[^"]+(\")', r'\1[REDACTED-USER]\2'), 101 | (r'(application\.process\.host = ")[^"]+(\")', r'\1[REDACTED-HOSTNAME]\2'), 102 | (r'(application\.process\.machine_id = ")[^"]+(\")', r'\1[REDACTED-MACHINE-ID]\2'), 103 | 104 | # Personal names and identifiers 105 | (r'(Connecting to )([A-Z][a-z]+ [A-Z][a-z]+)(\.\.\.)', r'\1[REDACTED-NAME]\3'), 106 | 107 | # Password related info (if present) 108 | (r'(password=)[^\s,;\'\"]+', r'\1[REDACTED-PASSWORD]'), 109 | (r'(password="?)[^"\']+("?)', r'\1[REDACTED-PASSWORD]\2'), 110 | (r'(psk="?)[^"\']+("?)', r'\1[REDACTED-PASSWORD]\2'), 111 | 112 | # Specific media/content identifiers 113 | (r'(media\.name = ")[^"]+(\")', r'\1[REDACTED-MEDIA]\2'), 114 | 115 | # Tokens and authentication 116 | (r'(token=)[^\s]+', r'\1[REDACTED-TOKEN]'), 117 | (r'(auth[-_]?token=)[^\s]+', r'\1[REDACTED-TOKEN]'), 118 | ] 119 | 120 | # Initialize log file attribute 121 | self.__log_file = None 122 | 123 | if self.__log_file_name != "": 124 | if self.__log_file_name.isdigit(): 125 | digit: int = int(self.__log_file_name) 126 | 127 | if digit in range(4): 128 | self.__log_level = digit 129 | else: 130 | self.log(LogLevel.Error, "Invalid log level provided") 131 | elif not self.__log_file_name.isdigit(): 132 | if not os.path.isfile(self.__log_file_name): 133 | self.__log_file = open(self.__log_file_name, "x") 134 | else: 135 | self.__log_file = open(self.__log_file_name, "a") 136 | else: 137 | self.log(LogLevel.Error, "Invalid option for argument log") 138 | 139 | def __del__(self): 140 | if hasattr(self, '_Logger__log_file') and self.__log_file is not None: 141 | self.__log_file.close() 142 | 143 | def __redact_sensitive_info(self, message: str) -> str: 144 | """Redacts sensitive information from log messages 145 | 146 | Args: 147 | message (str): The original log message 148 | 149 | Returns: 150 | str: The redacted log message 151 | """ 152 | # Skip redaction if not enabled 153 | if not self.__should_redact: 154 | return message 155 | 156 | redacted_message = message 157 | 158 | # Apply each redaction pattern 159 | for pattern, replacement in self.__redaction_patterns: 160 | redacted_message = re.sub(pattern, replacement, redacted_message, flags=re.IGNORECASE) 161 | 162 | return redacted_message 163 | 164 | def log(self, log_level: LogLevel, message: str): 165 | """Logs messages to a stream based on user arg 166 | 167 | Args: 168 | log_level (LogLevel): the log level, which consists of Debug, Info, Warn, Error 169 | message (str): the log message 170 | """ 171 | # Redact sensitive information 172 | redacted_message = self.__redact_sensitive_info(message) 173 | 174 | label = ( 175 | self.__labels[log_level].first 176 | if self.__add_color 177 | else self.__labels[log_level].second 178 | ) 179 | 180 | fmt = f"{get_current_time()} {label} {redacted_message}" 181 | 182 | self.__last_log_msg = fmt 183 | 184 | if log_level != LogLevel.Error and self.__should_log == False: 185 | return 186 | 187 | if self.__log_file_name != "": 188 | self.__log_to_file(fmt) 189 | print(fmt, file=stderr) 190 | elif log_level == LogLevel.Warn and self.__log_level < 3: 191 | print(fmt, file=stdout) 192 | elif log_level == LogLevel.Info and self.__log_level < 2: 193 | print(fmt, file=stdout) 194 | elif log_level == LogLevel.Debug and self.__log_level < 1: 195 | print(fmt, file=stdout) 196 | 197 | def get_last_log_msg(self) -> str: 198 | return self.__last_log_msg 199 | 200 | def __log_to_file(self, message: str): 201 | if not hasattr(self, '_Logger__log_file') or self.__log_file is None: 202 | return 203 | 204 | print(message, file=self.__log_file) 205 | -------------------------------------------------------------------------------- /src/utils/pair.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | T = TypeVar('T') 4 | U = TypeVar('U') 5 | 6 | 7 | class Pair(Generic[T, U]): 8 | def __init__(self, first: T, second: U) -> None: 9 | self.first: T = first 10 | self.second: U = second 11 | 12 | def __repr__(self) -> str: 13 | return f"Pair({self.first}, {self.second})" 14 | -------------------------------------------------------------------------------- /src/utils/settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import os 5 | from utils.logger import LogLevel, Logger 6 | 7 | CONFIG_DIR = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) 8 | CONFIG_PATH = os.path.join(CONFIG_DIR, "better-control") 9 | SETTINGS_FILE = os.path.join(CONFIG_PATH, "settings.json") 10 | 11 | def ensure_config_dir(logging: Logger) -> None: 12 | """Ensure the config directory exists 13 | 14 | Args: 15 | logging (Logger): Logger instance 16 | """ 17 | try: 18 | logging.log(LogLevel.Info, f"Ensuring config directory exists at {CONFIG_PATH}") 19 | os.makedirs(CONFIG_PATH, exist_ok=True) 20 | logging.log(LogLevel.Info, "Config directory check complete") 21 | except Exception as e: 22 | logging.log(LogLevel.Error, f"Error creating config directory: {e}") 23 | 24 | def load_settings(logging: Logger) -> dict: 25 | """Load settings from the settings file with validation""" 26 | ensure_config_dir(logging) 27 | default_settings = { 28 | "visibility": {}, 29 | "positions": {}, 30 | "usbguard_hidden_devices": [], 31 | "language": "en", 32 | "vertical_tabs": False, 33 | "vertical_tabs_icon_only": False 34 | } 35 | 36 | if not os.path.exists(SETTINGS_FILE): 37 | logging.log(LogLevel.Info, "Using default settings (file not found)") 38 | return default_settings 39 | 40 | try: 41 | with open(SETTINGS_FILE, 'r') as f: 42 | content = f.read().strip() 43 | if not content.startswith('{'): 44 | content = '{' + content # Fix malformed JSON 45 | settings = json.loads(content) 46 | logging.log(LogLevel.Info, f"Loaded settings from {SETTINGS_FILE}") 47 | 48 | if not isinstance(settings, dict): 49 | logging.log(LogLevel.Warn, "Invalid settings format - using defaults") 50 | return default_settings 51 | 52 | for key in default_settings: 53 | if key not in settings: 54 | settings[key] = default_settings[key] 55 | logging.log(LogLevel.Info, f"Added missing setting: {key}") 56 | 57 | return settings 58 | 59 | except Exception as e: 60 | logging.log(LogLevel.Error, f"Error loading settings: {e}") 61 | return default_settings 62 | 63 | def save_settings(settings: dict, logging: Logger) -> bool: 64 | """Save settings to the settings file with atomic write and validation""" 65 | try: 66 | ensure_config_dir(logging) 67 | 68 | if not isinstance(settings, dict): 69 | logging.log(LogLevel.Error, "Invalid settings - not a dictionary") 70 | return False 71 | 72 | default_settings = { 73 | "visibility": {}, 74 | "positions": {}, 75 | "usbguard_hidden_devices": [], 76 | "language": "en", 77 | "vertical_tabs": False, 78 | "vertical_tabs_icon_only": False 79 | } 80 | for key in default_settings: 81 | if key not in settings: 82 | settings[key] = default_settings[key] 83 | 84 | temp_path = SETTINGS_FILE + '.tmp' 85 | with open(temp_path, 'w') as f: 86 | json.dump(settings, f, indent=4) 87 | 88 | 89 | with open(temp_path, 'r') as f: 90 | json.load(f) 91 | 92 | os.replace(temp_path, SETTINGS_FILE) 93 | logging.log(LogLevel.Info, f"Settings saved successfully to {SETTINGS_FILE}") 94 | return True 95 | 96 | except Exception as e: 97 | logging.log(LogLevel.Error, f"Error saving settings: {e}") 98 | try: 99 | if 'temp_path' in locals() and os.path.exists(temp_path): 100 | os.unlink(temp_path) 101 | except: 102 | pass 103 | return False 104 | -------------------------------------------------------------------------------- /src/utils/usbguard_permissions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ############################# 3 | # This script is run when user presses "give permission" button on usbguard tab 4 | ############################# 5 | 6 | # USBGuard Permission Setup Script (Improved) 7 | set -euo pipefail 8 | 9 | echo "=== USBGuard Permission Setup ===" 10 | echo "This will configure your system to allow USBGuard access." 11 | 12 | # Verify root 13 | if [[ "$EUID" -ne 0 ]]; then 14 | echo "Please run this script with sudo or as root." 15 | exit 1 16 | fi 17 | 18 | # Determine invoking user 19 | USER_NAME="${SUDO_USER:-$(logname)}" 20 | echo "Configuring USBGuard for user: $USER_NAME" 21 | 22 | # Create usbguard group if it doesn't exist 23 | if ! getent group usbguard >/dev/null; then 24 | echo "Creating usbguard group..." 25 | groupadd usbguard 26 | else 27 | echo "usbguard group already exists." 28 | fi 29 | 30 | # Add user to usbguard group 31 | echo "Adding $USER_NAME to usbguard group..." 32 | usermod -aG usbguard "$USER_NAME" 33 | 34 | # Create or update udev rule 35 | UDEV_RULE_FILE="/etc/udev/rules.d/99-usbguard.rules" 36 | RULE='SUBSYSTEM=="usb", MODE="0660", TAG+="uaccess"' 37 | 38 | if [[ -f "$UDEV_RULE_FILE" ]]; then 39 | if grep -Fxq "$RULE" "$UDEV_RULE_FILE"; then 40 | echo "udev rule already present." 41 | else 42 | echo "Appending udev rule to $UDEV_RULE_FILE..." 43 | echo "$RULE" >> "$UDEV_RULE_FILE" 44 | fi 45 | else 46 | echo "Creating udev rule at $UDEV_RULE_FILE..." 47 | echo "$RULE" > "$UDEV_RULE_FILE" 48 | fi 49 | 50 | # Modify usbguard-daemon.conf safely 51 | CONF_FILE="/etc/usbguard/usbguard-daemon.conf" 52 | 53 | modify_conf_entry() { 54 | local key="$1" 55 | local value="$2" 56 | if grep -q "^$key=" "$CONF_FILE"; then 57 | if grep -q "^$key=.*\b$value\b" "$CONF_FILE"; then 58 | echo "$key already includes $value." 59 | else 60 | echo "Appending $value to $key..." 61 | sed -i "s/^$key=/&$value /" "$CONF_FILE" 62 | fi 63 | else 64 | echo "$key=$value" >> "$CONF_FILE" 65 | fi 66 | } 67 | 68 | modify_conf_entry "IPCAllowedUsers" "$USER_NAME" 69 | modify_conf_entry "IPCAllowedGroups" "usbguard" 70 | 71 | # Reload udev and restart usbguard 72 | echo "Reloading udev rules..." 73 | udevadm control --reload-rules 74 | 75 | echo "Restarting USBGuard service..." 76 | systemctl restart usbguard 77 | 78 | echo 79 | echo "USBGuard setup complete for user '$USER_NAME'." 80 | echo "Please log out and back in for group changes to take effect." 81 | echo "Note: You may need to reconnect USB devices after setup." 82 | --------------------------------------------------------------------------------