├── config.yaml ├── setup.sh ├── keyboard_listener ├── README.md └── razer_controller /config.yaml: -------------------------------------------------------------------------------- 1 | pywal: false 2 | i3: false 3 | log: false 4 | 5 | key_positions: 6 | # Define positions for individual keys, the tuple corrsponds to (row, column) of they keyboard as defioned by openrazer 7 | 1_key: 8 | - (1,1) 9 | 2_key: 10 | - (1,2) 11 | 3_key: 12 | - (1,3) 13 | 4_key: 14 | - (1,4) 15 | 5_key: 16 | - (1,5) 17 | 6_key: 18 | - (1,6) 19 | 7_key: 20 | - (1,7) 21 | 8_key: 22 | - (1,8) 23 | 9_key: 24 | - (1,9) 25 | 10_key: 26 | - (1,10) 27 | 28 | # Define positions for special keys 29 | super: 30 | - (5, 1) 31 | enter: 32 | - (3, 13) 33 | shift: 34 | - (4, 0) 35 | 36 | # Define groups of keys 37 | numbers: 38 | - (1, 1) 39 | - (1, 2) 40 | - (1, 3) 41 | - (1, 4) 42 | - (1, 5) 43 | - (1, 6) 44 | - (1, 7) 45 | - (1, 8) 46 | - (1, 9) 47 | - (1, 10) 48 | arrows: 49 | - (5, 14) 50 | - (5, 15) 51 | - (5, 16) 52 | - (4, 15) 53 | 54 | modes: 55 | # Base mode - applied when no keys are pressed 56 | base: 57 | rules: 58 | - keys: [all] 59 | color: [0,255,0] 60 | 61 | # Single-key modes 62 | super: 63 | rules: 64 | - keys: [numbers] 65 | color: [255,255,255] 66 | - keys: [super] 67 | color: [255, 255, 255] 68 | - keys: [enter] 69 | color: [255,255,255] 70 | - keys: [arrows] 71 | color: [255,255,255] 72 | - keys: [shift] 73 | color: [255, 255, 255] 74 | 75 | # Two-key combination modes are formated as KeyBeingHeld_NewKeyBeingHeld 76 | super_shift: 77 | rules: 78 | - keys: [numbers] 79 | color: [255,255,255] 80 | - keys: [super] 81 | color: [0, 255, 0] 82 | - keys: [shift] 83 | color: [255, 255, 255] 84 | - keys: [arrows] 85 | color: [0,0,255] 86 | 87 | shift_super: 88 | rules: 89 | - keys: [numbers] 90 | color: [255,255,255] 91 | - keys: [super] 92 | color: [0, 255, 0] 93 | - keys: [shift] 94 | color: [255, 255, 255] 95 | - keys: [arrows] 96 | color: [0,0,255] 97 | 98 | alt: 99 | rules: 100 | - keys: [1_key] 101 | color: [188,214,160] 102 | - keys: [2_key] 103 | color: [143,143,144] 104 | - keys: [3_key] 105 | color: [99,146,152] 106 | - keys: [4_key] 107 | color: [57,99,88] 108 | - keys: [5_key] 109 | color: [173,74,44] 110 | - keys: [6_key] 111 | color: [146,120,150] 112 | - keys: [7_key] 113 | color: [93,185,213] 114 | - keys: [8_key] 115 | color: [38,52,115] 116 | - keys: [9_key] 117 | color: [43,86,138] 118 | - keys: [10_key] 119 | color: [131,97,130] 120 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Razer Keyboard Highlighter Setup Script for Arch Linux 4 | # This script installs dependencies, sets up the config directory, and creates a systemd service 5 | 6 | # Configuration 7 | USER=$(whoami) 8 | CONFIG_DIR="$HOME/.config/razer-keyboard-highlighter" 9 | RAZER_CONTROLLER="razer_controller" 10 | KEYBOARD_LISTENER="keyboard_listener" 11 | SERVICE_NAME="keyboard_listener.service" 12 | CONFIG_NAME="config.yaml" 13 | 14 | # Create config directory 15 | echo "Creating config directory: $CONFIG_DIR" 16 | mkdir -p "$CONFIG_DIR" 17 | 18 | # Copy scripts to config directory 19 | echo "Copying script and config file to config directory..." 20 | sudo cp "$RAZER_CONTROLLER" "/usr/bin/" 21 | sudo cp "$KEYBOARD_LISTENER" "/usr/bin/" 22 | chmod +x "/usr/bin/$RAZER_CONTROLLER" 23 | chmod +x "/usr/bin/$KEYBOARD_LISTENER" 24 | 25 | # Install Arch Linux dependencies 26 | echo "Installing $USER required packages..." 27 | sudo pacman -Sy --noconfirm python python-pip openrazer-daemon python-openrazer python-watchdog python-yaml 28 | systemctl enable --now --user openrazer-daemon.service 29 | 30 | # Add user to plugdev group 31 | echo "Adding user to plugdev group..." 32 | sudo gpasswd -a $USER plugdev 33 | 34 | # Install Python dependencies 35 | echo "Installing Root Python packages..." 36 | sudo python -m venv /root/razer_keyboard_highlighter_venv 37 | sudo /root/razer_keyboard_highlighter_venv/bin/pip install --upgrade pip 38 | sudo /root/razer_keyboard_highlighter_venv/bin/pip install keyboard psutil 39 | 40 | # Create default config file if needed 41 | if [ ! -f "$CONFIG_DIR/config.yaml" ]; then 42 | echo "Creating default config.yaml..." 43 | cat > "$CONFIG_DIR/config.yaml" << 'EOL' 44 | pywal: false 45 | modes: 46 | base: 47 | rules: 48 | - keys: ['all'] 49 | color: '[255,0,0]' 50 | EOL 51 | fi 52 | 53 | # Create systemd service 54 | echo "Creating systemd service..." 55 | 56 | # Use current DISPLAY and XAUTHORITY values 57 | CURRENT_DISPLAY=${DISPLAY:-":0"} 58 | 59 | cat << EOL | sudo tee "/etc/systemd/system/$SERVICE_NAME" > /dev/null 60 | [Unit] 61 | Description=Keyboard Listener Service 62 | After=graphical.target display-manager.service 63 | Wants=graphical.target 64 | 65 | [Service] 66 | Type=simple 67 | User=root 68 | ExecStart=/usr/bin/keyboard_listener $USER 69 | Environment=DISPLAY=${CURRENT_DISPLAY} 70 | 71 | [Install] 72 | WantedBy=default.target 73 | EOL 74 | 75 | # Start the service 76 | echo "Starting service..." 77 | sudo systemctl daemon-reload 78 | sudo systemctl enable "$SERVICE_NAME" 79 | sudo systemctl start "$SERVICE_NAME" 80 | 81 | echo "Installation complete!" 82 | echo "The keyboard Listener service is now running." 83 | echo "" 84 | echo "Important: Log out and back in to apply group changes" 85 | echo "" 86 | echo "Service control:" 87 | echo " sudo systemctl status $SERVICE_NAME" 88 | echo " sudo systemctl restart $SERVICE_NAME" 89 | echo "" 90 | echo "View logs: tail -f $CONFIG_DIR/logs.txt" 91 | echo "Edit config: $CONFIG_DIR/config.yaml" 92 | -------------------------------------------------------------------------------- /keyboard_listener: -------------------------------------------------------------------------------- 1 | #!/root/razer_keyboard_highlighter_venv/bin/python 2 | import keyboard 3 | import sys 4 | import os 5 | import json 6 | import pwd 7 | import socket 8 | import struct 9 | import secrets 10 | import base64 11 | import hmac 12 | import psutil 13 | import hashlib 14 | 15 | def send_fd(sock, fd): 16 | """Send a file descriptor over a Unix socket""" 17 | sock.sendmsg([b'x'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, struct.pack("i", fd))]) 18 | 19 | def generate_secure_socket_name(): 20 | """Generate a cryptographically secure random socket name""" 21 | random_bytes = secrets.token_bytes(16) 22 | return base64.urlsafe_b64encode(random_bytes).decode('ascii').rstrip('=') 23 | 24 | def get_process_info(pid): 25 | """Get information about a process""" 26 | try: 27 | process = psutil.Process(pid) 28 | return { 29 | 'pid': pid, 30 | 'name': process.name(), 31 | 'exe': process.exe(), 32 | 'cmdline': process.cmdline(), 33 | 'username': process.username(), 34 | 'create_time': process.create_time() 35 | } 36 | except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): 37 | return None 38 | 39 | def verify_client_pid(conn, expected_uid): 40 | """Verify that the connecting process has the expected UID""" 41 | try: 42 | # Get peer credentials (SO_PEERCRED gets the credentials of the peer process) 43 | creds = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')) 44 | pid, uid, gid = struct.unpack('3i', creds) 45 | return pid, uid, gid 46 | except Exception as e: 47 | print(f"Error getting peer credentials: {e}") 48 | return None, None, None 49 | 50 | def verify_client_process(conn, expected_username, expected_exe_hash): 51 | """Verify that the connecting process is the expected one using executable hash""" 52 | try: 53 | # Get peer credentials (SO_PEERCRED gets the credentials of the peer process) 54 | creds = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')) 55 | pid, uid, gid = struct.unpack('3i', creds) 56 | 57 | # Get process information 58 | process_info = get_process_info(pid) 59 | if not process_info: 60 | print(f"Could not get information for process {pid}") 61 | return False 62 | 63 | # Verify the process is running as the expected user 64 | if process_info['username'] != expected_username: 65 | print(f"Process user mismatch: {process_info['username']} != {expected_username}") 66 | return False 67 | 68 | # For Python processes, check the command line instead of the executable 69 | if expected_exe_hash: 70 | # Check if this is a Python process running our script 71 | cmdline = ' '.join(process_info['cmdline']) 72 | if 'razer_controller' in cmdline: 73 | # Calculate hash of the script file instead of the Python interpreter 74 | script_path = None 75 | for arg in process_info['cmdline']: 76 | if 'razer_controller' in arg and os.path.exists(arg): 77 | script_path = arg 78 | break 79 | 80 | if script_path: 81 | with open(script_path, 'rb') as f: 82 | exe_data = f.read() 83 | actual_hash = hashlib.sha256(exe_data).hexdigest() 84 | 85 | if actual_hash != expected_exe_hash: 86 | print(f"Script hash mismatch: {actual_hash} != {expected_exe_hash}") 87 | return False 88 | else: 89 | print("Could not find script path in command line") 90 | return False 91 | else: 92 | print(f"Process is not running the expected script: {cmdline}") 93 | return False 94 | 95 | print(f"Connection from valid process: {process_info['name']} (PID: {pid})") 96 | print(f"Command line: {' '.join(process_info['cmdline'])}") 97 | return True 98 | 99 | except Exception as e: 100 | print(f"Error verifying client process: {e}") 101 | return False 102 | 103 | def authenticate_client(conn, expected_token): 104 | """Simple challenge-response authentication""" 105 | try: 106 | # Generate a random challenge 107 | challenge = secrets.token_bytes(16) 108 | conn.send(challenge) 109 | 110 | # Expect HMAC response 111 | response = conn.recv(32) 112 | if not response or len(response) != 32: 113 | return False 114 | 115 | # Verify the response 116 | expected_response = hmac.new(expected_token, challenge, 'sha256').digest() 117 | 118 | return hmac.compare_digest(response, expected_response) 119 | except Exception as e: 120 | print(f"Authentication error: {e}") 121 | return False 122 | 123 | def get_executable_hash(executable_path): 124 | """Calculate SHA256 hash of an executable""" 125 | try: 126 | with open(executable_path, 'rb') as f: 127 | exe_data = f.read() 128 | return hashlib.sha256(exe_data).hexdigest() 129 | except Exception as e: 130 | print(f"Error calculating executable hash: {e}") 131 | return None 132 | 133 | def main(): 134 | if len(sys.argv) < 2: 135 | print("Usage: python keyboard_listener.py ") 136 | sys.exit(1) 137 | 138 | user_name = sys.argv[1] 139 | 140 | try: 141 | user_info = pwd.getpwnam(user_name) 142 | uid = user_info.pw_uid 143 | gid = user_info.pw_gid 144 | 145 | # Calculate hash of the expected executable 146 | expected_exe_hash = get_executable_hash("/usr/bin/razer_controller") 147 | if not expected_exe_hash: 148 | print("Failed to calculate hash for /usr/bin/razer_controller") 149 | sys.exit(1) 150 | 151 | print(f"Expected executable hash: {expected_exe_hash}") 152 | 153 | # Create an anonymous pipe (not a named pipe/FIFO) 154 | read_fd, write_fd = os.pipe() 155 | 156 | # Generate secure values 157 | socket_name = generate_secure_socket_name() 158 | auth_token = secrets.token_bytes(32) # 256-bit token 159 | 160 | abstract_name = f"\0razer_keyboard_{socket_name}" 161 | 162 | # Create and bind the control socket 163 | control_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 164 | control_socket.bind(abstract_name) 165 | control_socket.listen(1) 166 | 167 | print("Control socket created with secure name") 168 | 169 | # Write socket info to a protected file for the user process 170 | socket_info_dir = f"/run/user/{uid}/razer-keyboard" 171 | os.makedirs(socket_info_dir, exist_ok=True) 172 | os.chown(socket_info_dir, uid, gid) 173 | os.chmod(socket_info_dir, 0o700) 174 | 175 | socket_info_file = os.path.join(socket_info_dir, "socket_info") 176 | with open(socket_info_file, 'w') as f: 177 | # Write socket name, auth token, and expected executable hash 178 | f.write(f"{socket_name}\n{auth_token.hex()}\n{expected_exe_hash}") 179 | os.chown(socket_info_file, uid, gid) 180 | os.chmod(socket_info_file, 0o600) 181 | 182 | # Set up a cleanup handler 183 | def cleanup(): 184 | try: 185 | if os.path.exists(socket_info_file): 186 | os.remove(socket_info_file) 187 | control_socket.close() 188 | except: 189 | pass 190 | 191 | import atexit 192 | atexit.register(cleanup) 193 | 194 | # Accept connection from user process 195 | print("Waiting for user process to connect...") 196 | conn, addr = control_socket.accept() 197 | print("Client connected") 198 | 199 | # Verify client process using executable hash 200 | if not verify_client_process(conn, user_name, expected_exe_hash): 201 | print("Connection from invalid process. Rejecting.") 202 | conn.close() 203 | sys.exit(1) 204 | 205 | # Verify client PID 206 | client_pid, client_uid, client_gid = verify_client_pid(conn, uid) 207 | if client_uid != uid: 208 | print(f"Security alert: Connection from different UID (expected {uid}, got {client_uid})") 209 | conn.close() 210 | sys.exit(1) 211 | 212 | # Authenticate client 213 | if not authenticate_client(conn, auth_token): 214 | print("Authentication failed. Closing connection.") 215 | conn.close() 216 | sys.exit(1) 217 | 218 | print("Client authenticated successfully") 219 | 220 | # Send the read end of the pipe to the user process 221 | send_fd(conn, read_fd) 222 | conn.close() 223 | 224 | # Close our copy of the read end (the user process now has it) 225 | os.close(read_fd) 226 | 227 | # Close the control socket to prevent further connections 228 | control_socket.close() 229 | 230 | # Remove the socket info file to prevent further connection attempts 231 | if os.path.exists(socket_info_file): 232 | os.remove(socket_info_file) 233 | 234 | # Open the write end as a file object 235 | with os.fdopen(write_fd, 'w') as pipe: 236 | def send_event(event_type, key_name): 237 | data = {"type": event_type, "key": key_name} 238 | pipe.write(json.dumps(data) + '\n') 239 | pipe.flush() 240 | 241 | keyboard.hook(lambda e: send_event( 242 | 'press' if e.event_type == keyboard.KEY_DOWN else 'release', 243 | e.name 244 | )) 245 | 246 | # Keep the process running 247 | try: 248 | keyboard.wait() 249 | except KeyboardInterrupt: 250 | print("Exiting...") 251 | finally: 252 | cleanup() 253 | 254 | except Exception as e: 255 | print(f"Error: {e}") 256 | import traceback 257 | traceback.print_exc() 258 | sys.exit(1) 259 | 260 | if __name__ == "__main__": 261 | main() 262 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Razer keyboard highlighter 2 | 3 | Support for highlighting keys when held according to a configuration file with pywal, i3wm and hyprland integration, supports key combinations of any order/size 4 | 5 | ## IMPORTANT 6 | I have done my best to secure this application such that keypress events cannot be sniffed by some third party, however, this is still theoritically possible and can be a vulnerability you are introducing in your system if you use wayland, the reason this is not a concern for x11 is because x11 has no protections against keyloggers whereas a keylogger must have sudo permissions on wayland. 7 | 8 | I also made a version for openrgb, I highly encourage you to use it instead as this is not a problem there! https://github.com/DuckTapeMan35/openrgb-keyboard-highlighter 9 | 10 | ## Requirements 11 | 12 | ### General requirements 13 | 14 | - i3wm (optional) 15 | - hyprland (optional) 16 | - pywal (optional) 17 | - python 18 | 19 | ### Python libraries 20 | 21 | - i3ipc (optional) 22 | - keyboard 23 | - watchdog 24 | - pyyaml 25 | - openrazer 26 | - psutil 27 | 28 | These libraries should be installed on the side of the user because `razer_controller` needs to run as user (no sudo permissions) 29 | 30 | `sudo pacman -S openrazer-daemon python-openrazer i3ipc watchdog pyyaml` 31 | 32 | To use openrazer the openrazer daemon must be set up, to do so follow the tutorial here: https://openrazer.github.io/#download 33 | 34 | `keyboard` and `psutil` need to installed as root for `keyboard_listener` to function, I recommend you create a venv in `/root/razer_keyboard_highlighter_venv` and install these libraries there 35 | 36 | ```bash 37 | sudo python -m venv /root/razer_keyboard_highlighter_venv 38 | sudo /root/razer_keyboard_highlighter_venv/bin/pip install --upgrade pip 39 | sudo /root/razer_keyboard_highlighter_venv/bin/pip install keyboard psutil 40 | ``` 41 | 42 | ## Setup 43 | 44 | ```bash 45 | git clone https://github.com/DuckTapeMan35/razer-keyboard-highlighter 46 | cd razer-keyboard-highlighter 47 | chmod +x setup.sh 48 | ./setup.sh 49 | ``` 50 | 51 | This will create a daemon that listens to your keypresses and writes them to a socket, then you can use the command `razer_controller` to start the application. If you want this to work on startup you need to start it in your window manager's config file (so it starts as a child process of the window manager), otherwise the window manager integrations will not work. If you don't use a window manager/don't want the integrations, you can make a daemon and start/enable it, however, note that `razer_controller` must be run as a user without root permissions and must also run after `keyboard_listener`. 52 | 53 | Afterwards simply edit the config file under `.config/razer-keyboard-highlighter/` (where the script will be placed), details on how a proper config file should look below, in this repository there is also my personal config file as an example. 54 | 55 | If wish to install this manually note that `razer_controller` must be placed under `/usr/bin/` or the `keyboard_listener` will not accept its attempt at a connection. Note, also that `razer_controller` must always be run after `keyboard_listener` 56 | 57 | ## Configuration file 58 | 59 | The configuration file should be named config.yaml and be placed under `.config/razer-keyboard-highlighter/` 60 | 61 | Here is my personal config file that I will be detailing the workings of: 62 | 63 | ```yaml 64 | pywal: true 65 | window_manager: hyprland 66 | log_level: info 67 | 68 | key_positions: 69 | # Define positions for individual keys, the tuple corrsponds to (row, column) of they keyboard as defioned by openrazer 70 | q: 71 | - (2, 1) 72 | d: 73 | - (3, 3) 74 | x: 75 | - (4, 3) 76 | z: 77 | - (4, 2) 78 | s: 79 | - (3, 2) 80 | p: 81 | - (2, 10) 82 | v: 83 | - (4, 5) 84 | b: 85 | - (4, 6) 86 | e: 87 | - (2, 3) 88 | 1_key: 89 | - (1,1) 90 | 2_key: 91 | - (1,2) 92 | 3_key: 93 | - (1,3) 94 | 4_key: 95 | - (1,4) 96 | 5_key: 97 | - (1,5) 98 | 6_key: 99 | - (1,6) 100 | 7_key: 101 | - (1,7) 102 | 8_key: 103 | - (1,8) 104 | 9_key: 105 | - (1,9) 106 | 10_key: 107 | - (1,10) 108 | 109 | # Define positions for special keys 110 | super: 111 | - (5, 1) 112 | enter: 113 | - (3, 13) 114 | shift: 115 | - (4, 0) 116 | alt: 117 | - (5, 2) 118 | space: 119 | - (5, 6) 120 | ctrl: 121 | - (5, 0) 122 | 123 | # Define groups of keys 124 | numbers: 125 | - (1, 1) 126 | - (1, 2) 127 | - (1, 3) 128 | - (1, 4) 129 | - (1, 5) 130 | - (1, 6) 131 | - (1, 7) 132 | - (1, 8) 133 | - (1, 9) 134 | - (1, 10) 135 | arrows: 136 | - (5, 14) 137 | - (5, 15) 138 | - (5, 16) 139 | - (4, 15) 140 | 141 | modes: 142 | # Base mode - applied when no keys are pressed 143 | base: 144 | rules: 145 | - keys: [all] 146 | color: color[1] 147 | - keys: [numbers] 148 | condition: non_empty_workspaces 149 | value: false 150 | color: color[3] 151 | - keys: [numbers] 152 | condition: non_empty_workspaces 153 | value: true 154 | color: color[7] 155 | 156 | # Single-key modes 157 | super: 158 | rules: 159 | - keys: [numbers] 160 | condition: non_empty_workspaces 161 | value: false 162 | color: color[3] 163 | - keys: [numbers] 164 | condition: non_empty_workspaces 165 | value: true 166 | color: color[7] 167 | - keys: [super] 168 | color: [255, 255, 255] 169 | - keys: [enter] 170 | color: color[7] 171 | - keys: [d] 172 | color: color[2] 173 | - keys: [x] 174 | color: [255, 0, 0] 175 | - keys: [z] 176 | color: color[3] 177 | - keys: [v] 178 | color: color[4] 179 | - keys: [b] 180 | color: color[5] 181 | - keys: [arrows] 182 | color: color[1] 183 | - keys: [shift] 184 | color: [255, 255, 255] 185 | - keys: [e] 186 | color: color[6] 187 | 188 | # Two-key combination modes are formated as KeyBeingHeld_NewKeyBeingHeld 189 | super_shift: 190 | rules: 191 | - keys: [numbers] 192 | condition: non_empty_workspaces 193 | value: false 194 | color: color[3] 195 | - keys: [numbers] 196 | condition: non_empty_workspaces 197 | value: true 198 | color: color[7] 199 | - keys: [super] 200 | color: [255, 255, 255] 201 | - keys: [q] 202 | color: [255, 0, 0] 203 | - keys: [shift] 204 | color: [255, 255, 255] 205 | - keys: [arrows] 206 | color: color[1] 207 | - keys: [s] 208 | color: color[1] 209 | - keys: [p] 210 | color: color[3] 211 | - keys: [space] 212 | color: color[4] 213 | 214 | # modes are order dependant so if you want both orders to work you need to replicate them and reverse the order here 215 | shift_super: 216 | rules: 217 | - keys: [numbers] 218 | condition: non_empty_workspaces 219 | value: false 220 | color: color[3] 221 | - keys: [numbers] 222 | condition: non_empty_workspaces 223 | value: true 224 | color: color[7] 225 | - keys: [super] 226 | color: [255, 255, 255] 227 | - keys: [q] 228 | color: [255, 0, 0] 229 | - keys: [shift] 230 | color: [255, 255, 255] 231 | - keys: [arrows] 232 | color: color[1] 233 | - keys: [s] 234 | color: color[1] 235 | - keys: [p] 236 | color: color[3] 237 | - keys: [space] 238 | color: color[4] 239 | 240 | alt: 241 | rules: 242 | - keys: [1_key] 243 | color: [188,214,160] 244 | - keys: [2_key] 245 | color: [143,143,144] 246 | - keys: [3_key] 247 | color: [99,146,152] 248 | - keys: [4_key] 249 | color: [57,99,88] 250 | - keys: [5_key] 251 | color: [173,74,44] 252 | - keys: [6_key] 253 | color: [146,120,150] 254 | - keys: [7_key] 255 | color: [93,185,213] 256 | - keys: [8_key] 257 | color: [38,52,115] 258 | - keys: [9_key] 259 | color: [43,86,138] 260 | - keys: [10_key] 261 | color: [131,97,130] 262 | - keys: [alt] 263 | color: [255,255,255] 264 | 265 | ctrl_alt: 266 | rules: 267 | - keys: [s] 268 | color: color[1] 269 | - keys: [alt] 270 | color: [255,255,255] 271 | - keys: [ctrl] 272 | color: [255,255,255] 273 | 274 | alt_ctrl: 275 | rules: 276 | - keys: [s] 277 | color: color[1] 278 | - keys: [alt] 279 | color: [255,255,255] 280 | - keys: [ctrl] 281 | color: [255,255,255] 282 | ``` 283 | 284 | ### Pywal 285 | `pywal: true/false`, will determine wether or not pywal will be integrated. 286 | 287 | ### window_manager 288 | 289 | `window_manager: i3/hyprland` determines wether or not i3/hyprland integration is needed, however if rules that need i3/hyprland integration are present on the config, even if it is omitted rules will be applied 290 | 291 | ### Log 292 | 293 | The line `log_level: debug/info/warning/error/critical` sets the level, by default `log_level` is `info`. 294 | 295 | ### Key positions 296 | 297 | Key positions follow the structure of 298 | 299 | ```yaml 300 | key_positions: 301 | key_name: 302 | - (2, 1) 303 | ``` 304 | 305 | key_name can be anything and the tuple corresponds to the (row, column) of the key as defined by openrazer. It is also possible to define a key as having several positions, that is, a key group. rules applied to a key group will apply to all keys in said group. Rules will be discussed shortly. 306 | 307 | ### Modes 308 | 309 | #### Base 310 | 311 | The base mode is of special importance, it represents what happens when no valid key combos are held, I recommend setting this to a single solid color or a solid color with workspaces. The reason why I don't support animations is because they have a fade-in effect, that is a quirk of the openrazer api and I can't do anything about it. Perhaps at a later date this feature will be added. 312 | 313 | #### Keys and Rules 314 | 315 | - Single keys 316 | 317 | Single keys follow the structure of: 318 | 319 | ```yaml 320 | KeyHeld: 321 | rules: 322 | - keys: [key_name] 323 | condition: non_empty_workspaces 324 | value: true/false 325 | color: color[pywal_color_numeber] or [R,G,B] 326 | ``` 327 | 328 | For now the only condition is non_empty_workspaces, for it to work the workspaces must be renamed to numbers (1-10) and it can only be applied to numbers. 329 | 330 | As an example if key_name is 6 and it's position corresponds to the 6 key on the keyboard and the value of the condition is true the key will be lit up with the given color if there is a window open on the workspace, if the value is false then it will be lit up with the provided color if there are no windows in the corresponding workspace. 331 | 332 | - n keys 333 | 334 | n keys refers to when n keys are being held together, they follow the structure of FirstKeyHeld_SecondKeyHeld_etc . As an example let's take super_shift, this mode and its rules will only trigger when first super is held and then shift is held. 335 | 336 | Note: if you don't care about order you need to add both super_shift and shift_super with the same rules. 337 | -------------------------------------------------------------------------------- /razer_controller: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | from openrazer.client import DeviceManager 4 | import threading 5 | import time 6 | import yaml 7 | import os 8 | import re 9 | import traceback 10 | import json 11 | from collections import deque 12 | from typing import Dict, List, Tuple, Any 13 | from watchdog.observers import Observer 14 | from watchdog.events import FileSystemEventHandler 15 | import subprocess 16 | import select 17 | import socket 18 | import struct 19 | import hmac 20 | import binascii 21 | import sys 22 | import hashlib 23 | 24 | # Conditionally import i3ipc only if needed 25 | try: 26 | import i3ipc 27 | I3_AVAILABLE = True 28 | except ImportError: 29 | I3_AVAILABLE = False 30 | 31 | # Create a memory-efficient logger formatter 32 | class MemoryEfficientFormatter(logging.Formatter): 33 | def format(self, record): 34 | # Use a simpler format for most messages 35 | if record.levelno <= logging.INFO: 36 | return f"{record.getMessage()}" 37 | else: 38 | # More detailed format for warnings and errors 39 | return f"{record.name} - {record.levelname} - {record.getMessage()}" 40 | 41 | # Update your setup_logger function 42 | def setup_logger(level=logging.INFO): 43 | # Create log directory if it doesn't exist 44 | log_dir = os.path.expanduser('~/.config/razer-keyboard-highlighter') 45 | os.makedirs(log_dir, exist_ok=True) 46 | log_file = os.path.join(log_dir, 'logs.txt') 47 | 48 | logger = logging.getLogger('razer_keyboard_highlighter') 49 | logger.setLevel(level) 50 | 51 | # Create memory-efficient formatter 52 | formatter = MemoryEfficientFormatter('%(asctime)s - %(message)s') 53 | 54 | # Create file handler 55 | fh = logging.FileHandler(log_file) 56 | fh.setLevel(level) 57 | fh.setFormatter(formatter) 58 | 59 | # Create console handler 60 | ch = logging.StreamHandler() 61 | ch.setLevel(level) 62 | ch.setFormatter(formatter) 63 | 64 | # Add handlers to logger 65 | logger.addHandler(fh) 66 | logger.addHandler(ch) 67 | 68 | return logger 69 | 70 | # Initialize with default level, will be updated after config load 71 | logger = setup_logger() 72 | 73 | class PywalFileHandler(FileSystemEventHandler): 74 | """Handles pywal color file changes""" 75 | def __init__(self, callback): 76 | super().__init__() 77 | self.callback = callback 78 | 79 | def on_modified(self, event): 80 | if event.src_path.endswith('colors'): 81 | self.callback() 82 | 83 | class ConfigFileHandler(FileSystemEventHandler): 84 | """Handles config file changes""" 85 | def __init__(self, callback): 86 | super().__init__() 87 | self.callback = callback 88 | 89 | def on_modified(self, event): 90 | if event.src_path.endswith('config.yaml'): 91 | self.callback() 92 | 93 | class KeyboardController: 94 | __slots__ = [ 95 | 'config', 'device_manager', 'razer_keyboard', 'rows', 'cols', 96 | 'key_positions', 'wm_type', 'pressed_keys', 'colors_lock', 97 | 'colors', 'key_listener', 'wm_thread', 'pywal_updated', 98 | 'config_updated', 'pywal_watchdog_observer', 'config_watchdog_observer', 99 | 'current_mode', 'modifier_keys', 'pipe_fd', 'wm_integration_enabled', 100 | 'wm_lock', 'current_key_states', 'need_redraw', 'last_non_empty_workspaces', 101 | 'pipe_thread', 'redraw_count', 'skip_count', '_string_cache' 102 | ] 103 | 104 | def __init__(self): 105 | try: 106 | # Initialize with error handling 107 | logger.info("Initializing keyboard controller...") 108 | 109 | # Load configuration first 110 | self.config = self.load_config() 111 | 112 | self._string_cache = {} 113 | 114 | # Set log level based on config 115 | log_level = self.config.get('log_level', 'INFO').upper() 116 | numeric_level = getattr(logging, log_level, logging.INFO) 117 | 118 | # Update logger level 119 | for handler in logger.handlers: 120 | handler.setLevel(numeric_level) 121 | logger.setLevel(numeric_level) 122 | 123 | logger.info(f"Log level set to: {log_level}") 124 | 125 | # Initialize device manager and find keyboard 126 | self.device_manager = DeviceManager() 127 | logger.info("Device manager created") 128 | self.razer_keyboard = self.find_keyboard() 129 | 130 | if not self.razer_keyboard: 131 | raise RuntimeError("Razer keyboard not found") 132 | 133 | # Get keyboard dimensions as integers 134 | self.rows = int(self.razer_keyboard.fx.advanced.rows) 135 | self.cols = int(self.razer_keyboard.fx.advanced.cols) 136 | logger.info(f"Keyboard dimensions: {self.rows} rows x {self.cols} cols") 137 | 138 | # Parse key positions 139 | self.key_positions = self.parse_key_positions() 140 | logger.info(f"Loaded key positions for: {', '.join(self.key_positions.keys())}") 141 | 142 | # Get window manager type from config 143 | self.wm_type = self.config.get('window_manager', 'i3').lower() # Default to i3 144 | if self.wm_type not in ['sway', 'i3', 'hyprland']: 145 | logger.warning(f"Invalid window manager '{self.wm_type}'. Defaulting to 'i3'") 146 | self.wm_type = 'i3' 147 | 148 | logger.info(f"Using window manager: {self.wm_type}") 149 | 150 | # Initialize other components 151 | self.pressed_keys = deque() # Track keys in press order 152 | self.colors_lock = threading.Lock() 153 | self.colors = self.load_colors() 154 | self.key_listener = None 155 | self.wm_thread = None 156 | self.pywal_updated = False 157 | self.config_updated = False 158 | self.pywal_watchdog_observer = None 159 | self.config_watchdog_observer = None 160 | self.current_mode = "base" 161 | 162 | # Define modifier keys 163 | self.modifier_keys = { 164 | 'super': ['windows', 'cmd', 'super'], 165 | 'shift': ['shift'], 166 | 'alt': ['alt', 'alt gr'], 167 | 'ctrl': ['ctrl', 'control'] 168 | } 169 | 170 | # Use FD passing instead of named pipes 171 | self.pipe_fd = None 172 | 173 | # Check if workspace integration is needed 174 | self.wm_integration_enabled = self.config.get('workspaces', False) or self.needs_wm_integration() 175 | logger.info(f"Workspace integration: {'ENABLED' if self.wm_integration_enabled else 'DISABLED'}") 176 | 177 | # Create lock only if workspace integration is enabled 178 | if self.wm_integration_enabled: 179 | self.wm_lock = threading.Lock() 180 | else: 181 | self.wm_lock = None 182 | 183 | # Ensure base mode is defined 184 | if 'modes' not in self.config: 185 | self.config['modes'] = {} 186 | if 'base' not in self.config['modes']: 187 | self.config['modes']['base'] = {'rules': [{'keys': ['all'], 'color': '(0,255,0)'}]} 188 | 189 | # Track current state for optimization 190 | self.current_key_states = [[(0, 0, 0) for _ in range(self.cols)] for _ in range(self.rows)] 191 | self.need_redraw = False 192 | self.last_non_empty_workspaces = set() 193 | 194 | logger.info("Keyboard controller initialized successfully") 195 | 196 | except Exception as e: 197 | logger.error(f"Initialization failed: {e}") 198 | logger.error(traceback.format_exc()) 199 | raise 200 | 201 | def get_cached_string(self, s): 202 | """Get a cached version of a string to avoid duplication""" 203 | if s not in self._string_cache: 204 | self._string_cache[s] = s 205 | return self._string_cache[s] 206 | 207 | def needs_wm_integration(self) -> bool: 208 | """Check if any rules require workspace information""" 209 | for mode_name, mode_config in self.config.get('modes', {}).items(): 210 | for rule in mode_config.get('rules', []): 211 | if rule.get('condition') == 'non_empty_workspaces': 212 | return True 213 | return False 214 | 215 | def load_config(self) -> Dict[str, Any]: 216 | """Load YAML configuration from script directory""" 217 | try: 218 | # Get config file 219 | script_dir = os.path.expanduser('~/.config/razer-keyboard-highlighter') 220 | config_path = os.path.join(script_dir, 'config.yaml') 221 | logger.info(f"Loading config from: {config_path}") 222 | 223 | if not os.path.exists(config_path): 224 | logger.info("Config file not found, using default configuration") 225 | return { 226 | 'pywal': True, 227 | 'workspaces': False, 228 | 'key_positions': {}, 229 | 'log_level': 'INFO', # Default log level 230 | 'modes': { 231 | 'base': { 232 | 'rules': [ 233 | {'keys': ['all'], 'color': '(0,255,0)'} 234 | ] 235 | } 236 | } 237 | } 238 | 239 | with open(config_path, 'r') as f: 240 | config = yaml.safe_load(f) 241 | 242 | # Set defaults if not present 243 | config.setdefault('pywal', False) 244 | config.setdefault('workspaces', False) 245 | config.setdefault('log_level', 'INFO') # Default log level 246 | 247 | logger.info("Config loaded successfully") 248 | return config 249 | 250 | except Exception as e: 251 | logger.error(f"Error loading config: {e}") 252 | logger.error(traceback.format_exc()) 253 | return {} 254 | 255 | def parse_key_positions(self) -> Dict[str, List[Tuple[int, int]]]: 256 | """Parse key positions from config, with memory optimization""" 257 | positions = {} 258 | 259 | # Create 'all' key group using actual keyboard dimensions 260 | positions['all'] = [(r, c) for r in range(self.rows) for c in range(self.cols)] 261 | 262 | # Load positions from config 263 | if 'key_positions' in self.config: 264 | for key, value in self.config['key_positions'].items(): 265 | try: 266 | # Intern the key name to reduce duplication 267 | interned_key = sys.intern(key) 268 | 269 | if isinstance(value, list): 270 | # Convert all elements to (int, int) 271 | converted = [] 272 | for item in value: 273 | if isinstance(item, (list, tuple)) and len(item) == 2: 274 | converted.append((int(item[0]), int(item[1]))) 275 | elif isinstance(item, str) and item.startswith('(') and item.endswith(')'): 276 | # Safely parse string tuple 277 | try: 278 | # Remove parentheses and split 279 | stripped = item.strip()[1:-1] 280 | parts = stripped.split(',') 281 | if len(parts) == 2: 282 | row = int(parts[0].strip()) 283 | col = int(parts[1].strip()) 284 | converted.append((row, col)) 285 | else: 286 | logger.warning(f"Invalid tuple format for key '{interned_key}': {item}") 287 | except ValueError as e: 288 | logger.warning(f"Error parsing position '{item}' for key '{interned_key}': {e}") 289 | else: 290 | logger.warning(f"Invalid position format for key '{interned_key}': {item}") 291 | positions[interned_key] = converted 292 | elif isinstance(value, (tuple, list)) and len(value) == 2: 293 | # Single position 294 | positions[interned_key] = [(int(value[0]), int(value[1]))] 295 | else: 296 | logger.warning(f"Invalid position format for key '{interned_key}': {value}") 297 | positions[interned_key] = [] 298 | except Exception as e: 299 | logger.warning(f"Error parsing position for key '{key}': {e}") 300 | positions[sys.intern(key)] = [] 301 | 302 | # Add default positions for essential keys as integers 303 | defaults = { 304 | 'super': [(5, 1)], 305 | 'enter': [(3, 13)], 306 | 'numbers': [(1,1), (1,2), (1,3), (1,4), (1,5), (1,6), (1,7), (1,8), (1,9), (1,10)], 307 | 'arrows': [(5,14), (5,15), (5,16), (4,15)], 308 | 'shift': [(4,0)], 309 | 'alt': [(5,2)], 310 | 'ctrl': [(5,0)], 311 | 'q': [(2,1)], 312 | 'd': [(3,3)], 313 | 'x': [(4,3)], 314 | 'z': [(4,2)], 315 | 'space': [(5,7)], 316 | 'tab': [(2,0)], 317 | 'esc': [(1,0)], 318 | 'backspace': [(1,15)], 319 | } 320 | 321 | for key, pos_list in defaults.items(): 322 | if key not in positions: 323 | # Convert to list of integer tuples and intern the key 324 | interned_key = sys.intern(key) 325 | positions[interned_key] = [(int(r), int(c)) for r, c in pos_list] 326 | logger.info(f"Added default position for key '{interned_key}'") 327 | 328 | # Ensure all positions are integers 329 | for key in list(positions.keys()): 330 | new_list = [] 331 | for pos in positions[key]: 332 | if isinstance(pos, (tuple, list)) and len(pos) == 2: 333 | new_list.append((int(pos[0]), int(pos[1]))) 334 | positions[key] = new_list 335 | 336 | return positions 337 | 338 | def find_keyboard(self): 339 | """Find Razer keyboard device by VID/PID""" 340 | logger.info("Searching for Razer keyboards by VID/PID...") 341 | 342 | # List of known Razer keyboard VID/PID combinations 343 | razer_keyboards = { 344 | "1532:010D", "1532:010E", "1532:010F", "1532:0118", "1532:011A", 345 | "1532:011B", "1532:011C", "1532:0202", "1532:0203", "1532:0204", 346 | "1532:0205", "1532:0209", "1532:020F", "1532:0210", "1532:0211", 347 | "1532:0214", "1532:0216", "1532:0217", "1532:021A", "1532:021E", 348 | "1532:021F", "1532:0220", "1532:0221", "1532:0224", "1532:0225", 349 | "1532:0226", "1532:0227", "1532:0228", "1532:022A", "1532:022C", 350 | "1532:022D", "1532:022F", "1532:0232", "1532:0233", "1532:0234", 351 | "1532:0235", "1532:0237", "1532:0239", "1532:023A", "1532:023B", 352 | "1532:023F", "1532:0240", "1532:0241", "1532:0243", "1532:0245", 353 | "1532:0246", "1532:024A", "1532:024B", "1532:024C", "1532:024D", 354 | "1532:024E", "1532:0252", "1532:0253", "1532:0255", "1532:0256", 355 | "1532:0257", "1532:0258", "1532:0259", "1532:025A", "1532:025C", 356 | "1532:025D", "1532:025E", "1532:0266", "1532:0268", "1532:0269", 357 | "1532:026A", "1532:026B", "1532:026C", "1532:026D", "1532:026E", 358 | "1532:026F", "1532:0270", "1532:0271", "1532:0276", "1532:0279", 359 | "1532:027A", "1532:0282", "1532:0287", "1532:028A", "1532:028B", 360 | "1532:028C", "1532:028D", "1532:028F", "1532:0290", "1532:0292", 361 | "1532:0293", "1532:0294", "1532:0295", "1532:0296", "1532:0298", 362 | "1532:029D", "1532:029E", "1532:029F", "1532:02A0", "1532:02A1", 363 | "1532:02A2", "1532:02A3", "1532:02A5", "1532:02A6", "1532:02B6", 364 | "1532:02B8", "1532:0A24" 365 | } 366 | 367 | for device in self.device_manager.devices: 368 | # Format VID/PID as "vid:pid" string 369 | vidpid = f"{device._vid:04X}:{device._pid:04X}" 370 | logger.debug(f"Checking device: {device.name} (VID:PID={vidpid})") 371 | 372 | if vidpid in razer_keyboards: 373 | logger.info(f"Using keyboard: {device.name} (VID:PID={vidpid})") 374 | return device 375 | logger.warning("No Razer keyboard found with known VID/PID") 376 | return None 377 | 378 | def load_colors(self) -> List[Tuple[int, int, int]]: 379 | """Load colors from pywal or use defaults""" 380 | if self.config.get('pywal', True): 381 | colors = self.read_wal_colors('/home/duck/.cache/wal/colors') 382 | if colors: 383 | logger.info(f"Loaded {len(colors)} colors from pywal") 384 | return colors 385 | else: 386 | logger.info("Using fallback colors") 387 | return [ 388 | (55, 59, 67), # Background 389 | (171, 178, 191), # Foreground 390 | (191, 97, 106), # Red 391 | (163, 190, 140), # Green 392 | (224, 175, 104), # Yellow 393 | (129, 162, 190), # Blue 394 | (180, 142, 173), # Magenta 395 | (139, 213, 202), # Cyan 396 | (92, 99, 112) # Light gray 397 | ] 398 | else: 399 | logger.info("Pywal disabled, using default colors") 400 | return [ 401 | (100, 100, 100), # Default color 402 | (200, 200, 200) # Highlight color 403 | ] 404 | 405 | def read_wal_colors(self, file_path: str) -> List[Tuple[int, int, int]]: 406 | """Read colors from pywal cache file""" 407 | try: 408 | rgb_colors = [] 409 | if not os.path.exists(file_path): 410 | logger.warning(f"Wal colors file not found at {file_path}") 411 | return [] 412 | 413 | with open(file_path, 'r') as file: 414 | for line in file: 415 | cleaned = line.strip() 416 | if not cleaned: 417 | continue 418 | hex_color = cleaned.lstrip('#').strip() 419 | if len(hex_color) == 6: 420 | r = int(hex_color[0:2], 16) 421 | g = int(hex_color[2:4], 16) 422 | b = int(hex_color[4:6], 16) 423 | rgb_colors.append((r, g, b)) 424 | return rgb_colors 425 | except Exception as e: 426 | logger.error(f"Error reading wal colors: {e}") 427 | return [] 428 | 429 | def resolve_color(self, color_spec: Any) -> Tuple[int, int, int]: 430 | """Convert color specification to RGB tuple""" 431 | try: 432 | # Handle RGB list 433 | if isinstance(color_spec, list) and len(color_spec) == 3: 434 | return tuple(color_spec) 435 | 436 | # Handle string specifications 437 | if isinstance(color_spec, str): 438 | # Handle color[n] specification 439 | match = re.match(r'color\[(\d+)\]', color_spec) 440 | if match: 441 | idx = int(match.group(1)) 442 | if idx < len(self.colors): 443 | return self.colors[idx] 444 | 445 | # Handle hex colors 446 | elif color_spec.startswith('#'): 447 | hex_color = color_spec[1:] 448 | if len(hex_color) == 6: 449 | r = int(hex_color[0:2], 16) 450 | g = int(hex_color[2:4], 16) 451 | b = int(hex_color[4:6], 16) 452 | return (r, g, b) 453 | except Exception as e: 454 | logger.error(f"Error resolving color {color_spec}: {e}") 455 | 456 | return (0, 0, 0) # Default to black 457 | 458 | def get_hyprland_workspaces(self): 459 | """Get list of non-empty workspaces from Hyprland""" 460 | try: 461 | # Use hyprctl to get workspace info 462 | result = subprocess.run( 463 | ['hyprctl', 'workspaces', '-j'], 464 | capture_output=True, 465 | text=True, 466 | timeout=1.0 467 | ) 468 | workspaces = json.loads(result.stdout) 469 | 470 | non_empty = [] 471 | for ws in workspaces: 472 | if ws['windows'] > 0: 473 | # Only consider workspaces with IDs 1-10 for keyboard highlighting 474 | if 1 <= ws['id'] <= 10: 475 | non_empty.append(str(ws['id'])) 476 | return non_empty 477 | except Exception as e: 478 | logger.error(f"Error getting Hyprland workspaces: {e}") 479 | return [] 480 | 481 | def get_i3_or_sway_workspaces(self): 482 | """Get list of non-empty workspaces from i3/sway""" 483 | try: 484 | i3 = i3ipc.Connection() 485 | workspaces = i3.get_workspaces() 486 | tree = i3.get_tree() 487 | workspace_names = [] 488 | 489 | for ws in workspaces: 490 | if ws.name == "__i3_scratch": 491 | continue 492 | for container in tree.workspaces(): 493 | if container.name == ws.name: 494 | if container.leaves(): 495 | workspace_names.append(ws.name) 496 | break 497 | return workspace_names 498 | except Exception as e: 499 | logger.error(f"Error getting i3/sway workspaces: {e}") 500 | return [] 501 | 502 | def find_non_empty_workspaces(self): 503 | """Get list of non-empty workspaces from the configured WM""" 504 | if not self.wm_integration_enabled: 505 | return [] 506 | 507 | try: 508 | if (self.wm_type == 'i3' or self.wm_type == 'sway') and I3_AVAILABLE: 509 | return self.get_i3_or_sway_workspaces() 510 | elif self.wm_type == 'hyprland': 511 | return self.get_hyprland_workspaces() 512 | 513 | except Exception as e: 514 | logger.error(f"Error getting workspaces: {e}") 515 | return [] 516 | 517 | def update_workspaces(self): 518 | """Update workspace status (only if integration enabled)""" 519 | if not self.wm_integration_enabled: 520 | return 521 | 522 | if self.wm_lock: 523 | with self.wm_lock: 524 | try: 525 | current_workspaces = set(self.find_non_empty_workspaces()) 526 | # Only mark for redraw if workspaces have changed 527 | if current_workspaces != self.last_non_empty_workspaces: 528 | self.last_non_empty_workspaces = current_workspaces 529 | self.need_redraw = True 530 | logger.info(f"Workspaces changed: {current_workspaces}") 531 | except Exception as e: 532 | logger.error(f"Error updating workspaces: {e}") 533 | else: 534 | try: 535 | current_workspaces = set(self.find_non_empty_workspaces()) 536 | # Only mark for redraw if workspaces have changed 537 | if current_workspaces != self.last_non_empty_workspaces: 538 | self.last_non_empty_workspaces = current_workspaces 539 | self.need_redraw = True 540 | logger.info(f"Workspaces changed: {current_workspaces}") 541 | except Exception as e: 542 | logger.error(f"Error updating workspaces: {e}") 543 | 544 | def reload_config(self): 545 | """Reload configuration from file""" 546 | with self.colors_lock: 547 | try: 548 | self.config = self.load_config() 549 | self.key_positions = self.parse_key_positions() 550 | self.colors = self.load_colors() 551 | 552 | # Update log level if changed 553 | log_level = self.config.get('log_level', 'INFO').upper() 554 | numeric_level = getattr(logging, log_level, logging.INFO) 555 | 556 | # Update logger level 557 | for handler in logger.handlers: 558 | handler.setLevel(numeric_level) 559 | logger.setLevel(numeric_level) 560 | logger.info(f"Log level updated to: {log_level}") 561 | 562 | # Re-evaluate workspace requirement 563 | self.wm_type = self.config.get('window_manager', 'i3').lower() 564 | self.wm_integration_enabled = self.config.get('workspaces', False) or self.needs_wm_integration() 565 | logger.info(f"Workspace integration: {'ENABLED' if self.wm_integration_enabled else 'DISABLED'}") 566 | logger.info("Configuration reloaded") 567 | 568 | self.config_updated = False 569 | # Force redraw after config reload 570 | self.need_redraw = True 571 | # Update lighting after reload 572 | self.update_lighting() 573 | except Exception as e: 574 | logger.error(f"Error reloading config: {e}") 575 | 576 | def get_keys_positions(self, key_spec: Any) -> List[Tuple[int, int]]: 577 | """Get positions for a key or key group""" 578 | try: 579 | if isinstance(key_spec, str): 580 | return self.key_positions.get(key_spec, []) 581 | elif isinstance(key_spec, list): 582 | positions = [] 583 | for k in key_spec: 584 | positions.extend(self.get_keys_positions(k)) 585 | return positions 586 | except Exception as e: 587 | logger.error(f"Error getting positions for {key_spec}: {e}") 588 | return [] 589 | 590 | def apply_rule(self, rule: Dict[str, Any], desired_state: Dict[Tuple[int, int], Tuple[int, int, int]]): 591 | """Apply a lighting rule to the desired state""" 592 | try: 593 | # Get key positions 594 | keys = rule.get('keys', []) 595 | positions = self.get_keys_positions(keys) 596 | if not positions: 597 | logger.warning(f"No positions found for keys: {keys}") 598 | return 599 | 600 | # Handle per-key colors 601 | if 'colors' in rule: 602 | logger.info(f"Applying per-key colors for {keys}") 603 | colors = [self.resolve_color(c) for c in rule['colors']] 604 | for i, pos in enumerate(positions): 605 | try: 606 | row = int(pos[0]) 607 | col = int(pos[1]) 608 | if i < len(colors): 609 | desired_state[(row, col)] = colors[i] 610 | except (ValueError, TypeError): 611 | continue 612 | 613 | # Handle conditional rules 614 | condition = rule.get('condition') 615 | if condition == 'non_empty_workspaces': 616 | # Skip if workspace integration not enabled 617 | if not self.wm_integration_enabled: 618 | logger.info("Skipping workspace condition - integration disabled") 619 | return 620 | 621 | value = rule.get('value') 622 | color = self.resolve_color(rule.get('color')) 623 | logger.info(f"Applying condition for {keys}: non_empty={value}") 624 | 625 | # Only apply to number keys 626 | if keys == ['numbers']: 627 | for i, pos in enumerate(positions): 628 | try: 629 | row = int(pos[0]) 630 | col = int(pos[1]) 631 | workspace_num = str(i + 1) 632 | if (workspace_num in self.last_non_empty_workspaces) == value: 633 | desired_state[(row, col)] = color 634 | except (ValueError, TypeError): 635 | continue 636 | return 637 | 638 | # Handle simple color rule 639 | if 'color' in rule: 640 | color = self.resolve_color(rule['color']) 641 | logger.debug(f"Setting {keys} to {color}") 642 | for pos in positions: 643 | try: 644 | row = int(pos[0]) 645 | col = int(pos[1]) 646 | desired_state[(row, col)] = color 647 | except (ValueError, TypeError): 648 | continue 649 | except Exception as e: 650 | logger.error(f"Error applying rule: {e}") 651 | logger.error(traceback.format_exc()) 652 | 653 | def normalize_key_str(self, key_name: str) -> str: 654 | """Normalize key name string with memory optimization""" 655 | name = key_name.lower() 656 | 657 | # Define a set of common keys for interning 658 | common_keys = { 659 | 'super', 'shift', 'alt', 'ctrl', 'enter', 'space', 'tab', 660 | 'esc', 'escape', 'backspace', 'up', 'down', 'left', 'right', 661 | 'windows', 'cmd', 'control', 'alt gr', '`', '~', '!', '@', 662 | '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+', 663 | '[', ']', '{', '}', '\\', '|', ';', ':', "'", '"', ',', '<', 664 | '.', '>', '/', '?', 'backtick', 'tilde', 'exclamation', 'at', 665 | 'hash', 'dollar', 'percent', 'caret', 'ampersand', 'asterisk', 666 | 'paren_left', 'paren_right', 'minus', 'underscore', 'equal', 667 | 'plus', 'bracket_left', 'bracket_right', 'brace_left', 'brace_right', 668 | 'backslash', 'pipe', 'semicolon', 'colon', 'apostrophe', 'quote', 669 | 'comma', 'less', 'period', 'greater', 'slash', 'question' 670 | } 671 | 672 | # Handle modifier keys 673 | for mod, aliases in self.modifier_keys.items(): 674 | if name in aliases: 675 | return sys.intern(mod) 676 | 677 | # Handle special keys 678 | special_keys = { 679 | 'enter': 'enter', 680 | 'space': 'space', 681 | 'tab': 'tab', 682 | 'esc': 'esc', 683 | 'escape': 'esc', 684 | 'backspace': 'backspace', 685 | 'up': 'up', 686 | 'down': 'down', 687 | 'left': 'left', 688 | 'right': 'right', 689 | '`': 'backtick', 690 | '~': 'tilde', 691 | '!': 'exclamation', 692 | '@': 'at', 693 | '#': 'hash', 694 | '$': 'dollar', 695 | '%': 'percent', 696 | '^': 'caret', 697 | '&': 'ampersand', 698 | '*': 'asterisk', 699 | '(': 'paren_left', 700 | ')': 'paren_right', 701 | '-': 'minus', 702 | '_': 'underscore', 703 | '=': 'equal', 704 | '+': 'plus', 705 | '[': 'bracket_left', 706 | ']': 'bracket_right', 707 | '{': 'brace_left', 708 | '}': 'brace_right', 709 | '\\': 'backslash', 710 | '|': 'pipe', 711 | ';': 'semicolon', 712 | ':': 'colon', 713 | "'": 'apostrophe', 714 | '"': 'quote', 715 | ',': 'comma', 716 | '<': 'less', 717 | '.': 'period', 718 | '>': 'greater', 719 | '/': 'slash', 720 | '?': 'question' 721 | } 722 | 723 | # Handle number keys 724 | if name.isdigit(): 725 | return sys.intern(name) if name in common_keys else name 726 | 727 | # Handle letter keys 728 | if len(name) == 1: 729 | normalized = name.lower() 730 | return sys.intern(normalized) if normalized in common_keys else normalized 731 | 732 | # Return special key if found, otherwise return original 733 | if name in special_keys: 734 | result = special_keys[name] 735 | return sys.intern(result) if result in common_keys else result 736 | 737 | # For any other key, intern if it's common 738 | return sys.intern(name) if name in common_keys else name 739 | 740 | def get_current_mode(self) -> str: 741 | """Determine current mode based on pressed keys with consistent ordering""" 742 | try: 743 | # 1. Check full sequence of pressed keys (including non-modifiers) 744 | if self.pressed_keys: 745 | full_sequence = '_'.join(self.pressed_keys) 746 | if full_sequence in self.config.get('modes', {}): 747 | self.current_mode = full_sequence 748 | logger.info(f"Active key sequence mode: {full_sequence}") 749 | return full_sequence 750 | 751 | # 2. Check modifier-only sequence (only modifier keys in press order) 752 | modifier_sequence = [ 753 | key for key in self.pressed_keys 754 | if key in ['super', 'shift', 'alt', 'ctrl'] 755 | ] 756 | if modifier_sequence: 757 | mode_name = '_'.join(modifier_sequence) 758 | if mode_name in self.config.get('modes', {}): 759 | self.current_mode = mode_name 760 | logger.info(f"Active modifier mode: {mode_name}") 761 | return mode_name 762 | 763 | # Fallback to base mode if no special mode is active 764 | return 'base' 765 | except Exception as e: 766 | logger.error(f"Error determining current mode: {e}") 767 | return 'base' 768 | 769 | def update_lighting(self): 770 | """Update keyboard lighting based on current state - only redraw if needed""" 771 | try: 772 | # Create desired state (start with all black) 773 | desired_state = {} 774 | for r in range(self.rows): 775 | for c in range(self.cols): 776 | desired_state[(r, c)] = (0, 0, 0) 777 | 778 | # Apply current mode with fallback 779 | current_mode = self.get_current_mode() 780 | mode_config = self.config.get('modes', {}).get(current_mode) 781 | 782 | # Fallback to base mode if current mode not defined 783 | if mode_config is None and current_mode != 'base': 784 | logger.info(f"Mode '{current_mode}' not defined, falling back to base") 785 | mode_config = self.config.get('modes', {}).get('base', {}) 786 | 787 | # If still no config, use empty 788 | if mode_config is None: 789 | mode_config = {} 790 | 791 | logger.info(f"Applying lighting for mode: {current_mode}") 792 | 793 | # Apply all rules for the current mode to desired state 794 | for rule in mode_config.get('rules', []): 795 | self.apply_rule(rule, desired_state) 796 | 797 | # Check if any keys have changed 798 | needs_update = False 799 | for (row, col), color in desired_state.items(): 800 | if self.current_key_states[row][col] != color: 801 | needs_update = True 802 | break 803 | 804 | # Only update if changes are detected 805 | if needs_update or self.need_redraw: 806 | # Clear keyboard (set all to black) 807 | for r in range(self.rows): 808 | for c in range(self.cols): 809 | self.razer_keyboard.fx.advanced.matrix[r, c] = (0, 0, 0) 810 | 811 | # Apply desired state 812 | for (row, col), color in desired_state.items(): 813 | try: 814 | self.razer_keyboard.fx.advanced.matrix[row, col] = color 815 | self.current_key_states[row][col] = color 816 | except Exception as e: 817 | logger.error(f"Error setting key at ({row}, {col}): {e}") 818 | 819 | # Draw changes 820 | self.razer_keyboard.fx.advanced.draw() 821 | logger.info("Keyboard redrawn due to changes") 822 | self.need_redraw = False 823 | else: 824 | logger.info("No changes detected, skipping redraw") 825 | except Exception as e: 826 | logger.error(f"Error updating lighting: {e}") 827 | logger.error(traceback.format_exc()) 828 | 829 | def recv_fd(self, sock): 830 | """Receive a file descriptor from a Unix socket""" 831 | data, ancdata, flags, addr = sock.recvmsg(1, socket.CMSG_LEN(struct.calcsize('i'))) 832 | for cmsg_level, cmsg_type, cmsg_data in ancdata: 833 | if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS: 834 | # Unpack the file descriptor from the ancillary data 835 | fd = struct.unpack('i', cmsg_data)[0] 836 | return fd 837 | return None 838 | 839 | def respond_to_challenge(self, conn, token): 840 | """Respond to authentication challenge""" 841 | try: 842 | # Wait for challenge with timeout 843 | ready = select.select([conn], [], [], 5.0) # 5 second timeout 844 | if not ready[0]: 845 | raise RuntimeError("Timeout waiting for challenge") 846 | 847 | challenge = conn.recv(16) 848 | if not challenge or len(challenge) != 16: 849 | raise RuntimeError("Invalid challenge received") 850 | 851 | response = hmac.new(token, challenge, 'sha256').digest() 852 | conn.send(response) 853 | except Exception as e: 854 | logger.error(f"Error in challenge response: {e}") 855 | raise 856 | 857 | def connect_via_fd_passing(self): 858 | """Connect to the root process and receive the pipe file descriptor""" 859 | try: 860 | # Get the socket info from the protected file 861 | uid = os.getuid() 862 | socket_info_file = f"/run/user/{uid}/razer-keyboard/socket_info" 863 | 864 | # Wait for the file to be created with timeout 865 | timeout = 30 # 30 second timeout 866 | start_time = time.time() 867 | while time.time() - start_time < timeout: 868 | if os.path.exists(socket_info_file): 869 | break 870 | time.sleep(0.1) 871 | else: 872 | raise RuntimeError("Socket info file not found. Is the root process running?") 873 | 874 | # Read the socket name, auth token, and expected executable hash 875 | with open(socket_info_file, 'r') as f: 876 | lines = f.read().splitlines() 877 | if len(lines) < 3: 878 | raise RuntimeError("Invalid socket info file format") 879 | 880 | socket_name = lines[0].strip() 881 | auth_token_hex = lines[1].strip() 882 | expected_exe_hash = lines[2].strip() 883 | 884 | # Verify our script hash matches the expected one 885 | script_path = sys.argv[0] # Path to this script 886 | with open(script_path, 'rb') as f: 887 | script_data = f.read() 888 | current_hash = hashlib.sha256(script_data).hexdigest() 889 | 890 | if current_hash != expected_exe_hash: 891 | raise RuntimeError(f"Script hash mismatch: {current_hash} != {expected_exe_hash}") 892 | 893 | # Decode the auth token 894 | try: 895 | auth_token = binascii.unhexlify(auth_token_hex) 896 | except: 897 | raise RuntimeError("Failed to decode authentication token") 898 | 899 | # Create abstract socket name 900 | abstract_name = f"\0razer_keyboard_{socket_name}" 901 | 902 | # Connect to the control socket with timeout 903 | control_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 904 | control_socket.settimeout(10.0) # 10 second timeout 905 | 906 | try: 907 | control_socket.connect(abstract_name) 908 | except socket.error as e: 909 | logger.error(f"Failed to connect to control socket: {e}") 910 | logger.error("This might mean the root process has already closed the socket after a connection") 911 | raise 912 | 913 | # Respond to authentication challenge with proper error handling 914 | try: 915 | # Wait for challenge with timeout 916 | ready = select.select([control_socket], [], [], 5.0) 917 | if not ready[0]: 918 | raise RuntimeError("Timeout waiting for challenge") 919 | 920 | challenge = control_socket.recv(16) 921 | if not challenge or len(challenge) != 16: 922 | raise RuntimeError("Invalid challenge received") 923 | 924 | response = hmac.new(auth_token, challenge, 'sha256').digest() 925 | control_socket.send(response) 926 | except Exception as e: 927 | control_socket.close() 928 | raise RuntimeError(f"Challenge response failed: {e}") 929 | 930 | # Receive the file descriptor 931 | self.pipe_fd = self.recv_fd(control_socket) 932 | control_socket.close() 933 | 934 | if self.pipe_fd is None: 935 | raise RuntimeError("Failed to receive file descriptor from root process") 936 | 937 | logger.info("Received pipe file descriptor from root process") 938 | 939 | # Start thread to read events 940 | self.pipe_thread = threading.Thread(target=self.read_pipe_events, daemon=True) 941 | self.pipe_thread.start() 942 | logger.info("Pipe reader thread started") 943 | 944 | except Exception as e: 945 | logger.error(f"Error connecting via FD passing: {e}") 946 | logger.error(traceback.format_exc()) 947 | raise 948 | 949 | def read_pipe_events(self): 950 | """Read keyboard events from the pipe""" 951 | try: 952 | logger.info("Starting pipe reader thread") 953 | # Use bytes buffer instead of string buffer 954 | buffer = b"" 955 | 956 | while self.pipe_fd: 957 | # Check if there's data to read 958 | r, _, _ = select.select([self.pipe_fd], [], [], 0.1) 959 | if not r: 960 | continue 961 | 962 | try: 963 | # Read data as bytes 964 | data = os.read(self.pipe_fd, 4096) 965 | 966 | if not data: 967 | logger.info("Pipe closed by writer") 968 | break 969 | 970 | # Add to buffer and split into lines 971 | buffer += data 972 | lines = buffer.split(b'\n') 973 | 974 | # Process complete lines, keep incomplete in buffer 975 | buffer = lines[-1] # Last element might be incomplete 976 | for line in lines[:-1]: 977 | try: 978 | # Decode only when needed 979 | line_str = line.decode('utf-8') 980 | event = json.loads(line_str) 981 | 982 | # Use cached/interned strings for event processing 983 | event_type = self.get_cached_string(event['type']) 984 | event_key = self.normalize_key_str(event['key']) 985 | 986 | if event_type == 'press': 987 | self.on_press_str(event_key) 988 | elif event_type == 'release': 989 | self.on_release_str(event_key) 990 | except (json.JSONDecodeError, UnicodeDecodeError): 991 | logger.error(f"Invalid data: {line}") 992 | except KeyError: 993 | logger.error(f"Missing keys in event: {line}") 994 | except Exception as e: 995 | logger.error(f"Error processing event: {e}") 996 | 997 | except BlockingIOError: 998 | # No data available 999 | pass 1000 | except OSError as e: 1001 | if e.errno == 11: # Resource temporarily unavailable 1002 | continue 1003 | else: 1004 | logger.error(f"OSError in pipe read: {e}") 1005 | break 1006 | except Exception as e: 1007 | logger.error(f"Unexpected error in pipe read: {e}") 1008 | break 1009 | 1010 | except Exception as e: 1011 | logger.error(f"Error in pipe reader: {e}") 1012 | logger.error(traceback.format_exc()) 1013 | finally: 1014 | if hasattr(self, 'pipe_fd') and self.pipe_fd: 1015 | os.close(self.pipe_fd) 1016 | self.pipe_fd = None 1017 | 1018 | def on_press_str(self, key_name: str): 1019 | """Handle key press events from string""" 1020 | try: 1021 | key_identifier = self.normalize_key_str(key_name) 1022 | 1023 | logger.debug(f"KEY PRESS: {key_name} -> {key_identifier}") 1024 | 1025 | # Add to pressed keys if not already present 1026 | if key_identifier not in self.pressed_keys: 1027 | self.pressed_keys.append(key_identifier) 1028 | logger.debug(f"Keys pressed: {list(self.pressed_keys)}") 1029 | 1030 | # Update lighting 1031 | self.update_lighting() 1032 | 1033 | # Update workspaces when modifier state changes (only if integration enabled) 1034 | if self.wm_integration_enabled and key_identifier in ['super', 'alt']: 1035 | self.update_workspaces() 1036 | except Exception as e: 1037 | logger.error(f"Error in on_press: {e}") 1038 | 1039 | def on_release_str(self, key_name: str): 1040 | """Handle key release events from string""" 1041 | try: 1042 | key_identifier = self.normalize_key_str(key_name) 1043 | 1044 | logger.debug(f"KEY RELEASE: {key_name} -> {key_identifier}") 1045 | 1046 | # Remove from pressed keys 1047 | if key_identifier in self.pressed_keys: 1048 | self.pressed_keys.remove(key_identifier) 1049 | logger.debug(f"Keys pressed: {list(self.pressed_keys)}") 1050 | 1051 | # Update lighting 1052 | self.update_lighting() 1053 | except Exception as e: 1054 | logger.error(f"Error in on_release: {e}") 1055 | 1056 | def listen_hyprland_events(self): 1057 | """Listen for Hyprland workspace events""" 1058 | try: 1059 | # Get the Hyprland instance signature 1060 | instance_sig = os.getenv('HYPRLAND_INSTANCE_SIGNATURE') 1061 | if not instance_sig: 1062 | logger.error("HYPRLAND_INSTANCE_SIGNATURE not set") 1063 | return 1064 | 1065 | # Get XDG runtime directory 1066 | xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR') 1067 | if not xdg_runtime_dir: 1068 | logger.error("XDG_RUNTIME_DIR not set, using fallback") 1069 | xdg_runtime_dir = f"/run/user/{os.getuid()}" 1070 | 1071 | # Correct socket path using XDG_RUNTIME_DIR 1072 | socket_path = os.path.join(xdg_runtime_dir, 'hypr', instance_sig, '.socket2.sock') 1073 | 1074 | if not os.path.exists(socket_path): 1075 | logger.error(f"Hyprland socket not found at {socket_path}") 1076 | return 1077 | 1078 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 1079 | sock.connect(socket_path) 1080 | 1081 | logger.info("Connected to Hyprland event socket") 1082 | 1083 | while True: 1084 | # Check if we should stop 1085 | if not self.wm_integration_enabled: 1086 | break 1087 | 1088 | ready = select.select([sock], [], [], 1) 1089 | if ready[0]: 1090 | data = sock.recv(4096) 1091 | if not data: 1092 | break 1093 | 1094 | event = data.decode().strip() 1095 | # Handle relevant events 1096 | if event.startswith(("workspace>>", "openwindow>>", "closewindow>>", "movewindow>>")): 1097 | logger.debug(f"Hyprland event: {event}") 1098 | self.update_workspaces() 1099 | self.update_lighting() 1100 | except Exception as e: 1101 | logger.error(f"Error in Hyprland listener: {e}") 1102 | finally: 1103 | try: 1104 | sock.close() 1105 | except: 1106 | pass 1107 | 1108 | def start_wm_listener(self): 1109 | """Listen for window manager events""" 1110 | if not self.wm_integration_enabled: 1111 | return 1112 | 1113 | try: 1114 | if (self.wm_type == 'i3' or self.wm_type == 'sway') and I3_AVAILABLE: 1115 | i3 = i3ipc.Connection() 1116 | i3.on('window', self.on_wm_event) 1117 | logger.info("Starting i3 event listener") 1118 | i3.main() 1119 | elif self.wm_type == 'hyprland': 1120 | logger.info("Starting Hyprland event listener") 1121 | self.listen_hyprland_events() 1122 | except Exception as e: 1123 | logger.error(f"Error in WM listener: {e}") 1124 | 1125 | def on_wm_event(self, i3, event): 1126 | """Handle window manager events""" 1127 | if not self.wm_integration_enabled: 1128 | return 1129 | 1130 | try: 1131 | if (self.wm_type == 'i3' or self.wm_type == 'sway'): 1132 | if event.change in ['new', 'close', 'move']: 1133 | self.update_workspaces() 1134 | self.update_lighting() 1135 | except Exception as e: 1136 | logger.error(f"Error in WM event handler: {e}") 1137 | 1138 | def start_pywal_watcher(self): 1139 | """Start watching pywal color file for changes""" 1140 | pywal_path = os.path.expanduser('~/.cache/wal') 1141 | if not os.path.exists(pywal_path): 1142 | return 1143 | 1144 | pywal_event_handler = PywalFileHandler(self.handle_pywal_update) 1145 | self.pywal_watchdog_observer = Observer() 1146 | self.pywal_watchdog_observer.schedule(pywal_event_handler, pywal_path, recursive=False) 1147 | self.pywal_watchdog_observer.start() 1148 | logger.info(f"Started watching pywal colors at {pywal_path}") 1149 | 1150 | def start_config_watcher(self): 1151 | """Start watching config file for changes""" 1152 | config_path = os.path.expanduser('~/.config/razer-keyboard-highlighter') 1153 | if not os.path.exists(config_path): 1154 | os.makedirs(config_path, exist_ok=True) 1155 | logger.info(f"Created config directory: {config_path}") 1156 | 1157 | config_event_handler = ConfigFileHandler(self.handle_config_update) 1158 | self.config_watchdog_observer = Observer() 1159 | self.config_watchdog_observer.schedule(config_event_handler, config_path, recursive=False) 1160 | self.config_watchdog_observer.start() 1161 | logger.info(f"Started watching config at {config_path}") 1162 | 1163 | def handle_pywal_update(self): 1164 | """Called when pywal colors change - update lighting""" 1165 | logger.info("Pywal colors updated - reloading") 1166 | self.pywal_updated = True 1167 | 1168 | def handle_config_update(self): 1169 | """Called when configs change - update lighting""" 1170 | logger.info("Config updated - reloading") 1171 | self.config_updated = True 1172 | 1173 | def reload_pywal_colors(self): 1174 | """Reload colors and update lighting""" 1175 | with self.colors_lock: 1176 | self.colors = self.load_colors() 1177 | self.pywal_updated = False 1178 | # Force redraw after color reload 1179 | self.need_redraw = True 1180 | self.update_lighting() 1181 | logger.info("Colors reloaded from pywal") 1182 | 1183 | def run(self): 1184 | """Main application loop""" 1185 | try: 1186 | # Connect to the root process using FD passing 1187 | self.connect_via_fd_passing() 1188 | 1189 | if self.wm_integration_enabled: 1190 | self.update_workspaces() 1191 | 1192 | # Start WM listener thread only if enabled 1193 | self.wm_thread = threading.Thread(target=self.start_wm_listener, daemon=True) 1194 | self.wm_thread.start() 1195 | logger.info(f"{self.wm_type} listener started") 1196 | 1197 | # Start pywal file watcher if enabled 1198 | if self.config.get('pywal', True): 1199 | self.start_pywal_watcher() 1200 | logger.info("Pywal file watcher started") 1201 | 1202 | self.start_config_watcher() 1203 | logger.info("Config file watcher started") 1204 | 1205 | # Apply initial lighting 1206 | self.update_lighting() 1207 | 1208 | # Main loop 1209 | logger.info("Entering main loop (Press Ctrl+C to exit)") 1210 | while True: 1211 | time.sleep(1) 1212 | # Check for pywal updates 1213 | if self.pywal_updated: 1214 | self.reload_pywal_colors() 1215 | # Check for config updates 1216 | if self.config_updated: 1217 | self.reload_config() 1218 | 1219 | # Periodically update workspaces only if integration enabled 1220 | if self.wm_integration_enabled: 1221 | self.update_workspaces() 1222 | except KeyboardInterrupt: 1223 | logger.info("Exiting...") 1224 | if hasattr(self, 'pipe_fd') and self.pipe_fd: 1225 | os.close(self.pipe_fd) 1226 | self.pipe_fd = None 1227 | if self.pywal_watchdog_observer: 1228 | self.pywal_watchdog_observer.stop() 1229 | self.pywal_watchdog_observer.join() 1230 | if self.config_watchdog_observer: 1231 | self.config_watchdog_observer.stop() 1232 | self.config_watchdog_observer.join() 1233 | # Turn off keyboard lights 1234 | try: 1235 | for r in range(self.rows): 1236 | for c in range(self.cols): 1237 | self.razer_keyboard.fx.advanced.matrix[r, c] = (0, 0, 0) 1238 | self.razer_keyboard.fx.advanced.draw() 1239 | except: 1240 | pass 1241 | except Exception as e: 1242 | logger.error(f"Error in main loop: {e}") 1243 | logger.error(traceback.format_exc()) 1244 | 1245 | if __name__ == "__main__": 1246 | try: 1247 | controller = KeyboardController() 1248 | controller.run() 1249 | except Exception as e: 1250 | logger.critical(f"Critical error: {e}") 1251 | logger.critical(traceback.format_exc()) 1252 | --------------------------------------------------------------------------------