├── .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 | [](https://aur.archlinux.org/packages/better-control-git)
10 | [](LICENSE)
11 | [](https://github.com/better-ecosystem/better-control/stargazers)
12 | [](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 | Category |
183 | Compatibility |
184 |
185 |
186 | Operating System |
187 | Linux |
188 |
189 |
190 | Distributions |
191 | Arch-based ✓ • Fedora-based ✓ • Debian-based ✓ • Void ✓ • Alpine ✓ |
192 |
193 |
194 | Desktop Environments |
195 | GNOME (tested) ✓ • KDE Plasma (tested) ✓ • XFCE • LXDE/LXQT |
196 |
197 |
198 | Window Managers |
199 | Hyprland (tested) ✓ • Sway (tested) ✓ • i3 • Openbox • Fluxbox |
200 |
201 |
202 | Display Protocol |
203 | Wayland (recommended) ✓ • X11 (partial functionality) |
204 |
205 |
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 |
--------------------------------------------------------------------------------