diff --git a/README.md b/README.md index f32a30a..e7d37c2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,21 @@ # Inks Scripts -To call these mine would be largely a lie as many of them were created by [Derek Taylor](https://gitlab.com/dwt1) and [Luke Smith](https://gitlab.com/LukeSmithxyz). +To call these mine would be largely a lie as many of them were created by +[Derek Taylor](https://gitlab.com/dwt1) and +[Luke Smith](https://gitlab.com/LukeSmithxyz). I also use +[Layerx's dmenu-bluetooth](https://github.com/Layerex/dmenu-bluetooth) and +[firecat53's networkmanager-dmenu](https://github.com/firecat53/networkmanager-dmenu). There are a few scripts provided for installing, removing and updating. - `install` simply copies the scripts to `$HOME/.local/bin` -- `install-system` copies them to `/usr/local/bin` -- `uninstall-system` removes them from `/usr/local/bin` -- and `update-voidrice` will copy those that have been stolen directly from the aforementioned Luke Smith to the scripts folder. Note that this assumes Luke's `voidrice` repo is sitting as an adjacent sibling to the `inks-scripts` repo under the same parent. +- `uninstall` removes them from `$HOME/.local/bin` +- `update-voidrice` will copy the scripts that I use from the aforementioned + Luke Smith to the scripts folder. +- `update-dmenu-bluetooth` will update the dmenu-bluetooth script. +- `update-networkmanager-bluetooth` will...? You get it. -See [ink-os](https://gitlab.inkletblot.com/inkletblot/ink-os) for their usage and more information. +I use the scripts mostly stand alone but I have another repo +[ink-os](https://gitlab.inkletblot.com/inkletblot/ink-os) which leverages them +to complete a fresh system install on top of arch, and (although it's a WIP) +ubuntu. diff --git a/scripts/dmenu-bluetooth b/scripts/dmenu-bluetooth new file mode 100644 index 0000000..47f5d32 --- /dev/null +++ b/scripts/dmenu-bluetooth @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# _ _ _ _ _ _ +# __| |_ __ ___ ___ _ __ _ _ | |__ | |_ _ ___| |_ ___ ___ | |_ | |__ +# / _` | '_ ` _ \ / _ \ '_ \| | | |_____| '_ \| | | | |/ _ \ __/ _ \ / _ \| __|| '_ \ +# | (_| | | | | | | __/ | | | |_| |_____| |_) | | |_| | __/ || (_) | (_) | |_ | | | | +# \__,_|_| |_| |_|\___|_| |_|\__,_| |_.__/|_|\__,_|\___|\__\___/ \___/ \__||_| |_| +# +# Author: Nick Clyde (clydedroid) +# dmenu support by: Layerex +# +# A script that generates a dmenu menu that uses bluetoothctl to +# connect to bluetooth devices and display status info. +# +# Inspired by networkmanager-dmenu (https://github.com/firecat53/networkmanager-dmenu) +# Thanks to x70b1 (https://github.com/polybar/polybar-scripts/tree/master/polybar-scripts/system-bluetooth-bluetoothctl) +# +# Depends on: +# Arch repositories: dmenu, bluez-utils (contains bluetoothctl) + +# Constants +divider="---------" +goback="Back" + +# Checks if bluetooth controller is powered on +power_on() { + if bluetoothctl show | grep -F -q "Powered: yes"; then + return 0 + else + return 1 + fi +} + +# Toggles power state +toggle_power() { + if power_on; then + bluetoothctl power off + show_menu + else + if rfkill list bluetooth | grep -F -q 'blocked: yes'; then + rfkill unblock bluetooth && sleep 3 + fi + bluetoothctl power on + show_menu + fi +} + +# Checks if controller is scanning for new devices +scan_on() { + if bluetoothctl show | grep -F -q "Discovering: yes"; then + echo "Scan: on" + return 0 + else + echo "Scan: off" + return 1 + fi +} + +# Toggles scanning state +toggle_scan() { + if scan_on; then + kill "$(pgrep -F -f "bluetoothctl scan on")" + bluetoothctl scan off + show_menu + else + bluetoothctl scan on & + echo "Scanning..." + sleep 5 + show_menu + fi +} + +# Checks if controller is able to pair to devices +pairable_on() { + if bluetoothctl show | grep -F -q "Pairable: yes"; then + echo "Pairable: on" + return 0 + else + echo "Pairable: off" + return 1 + fi +} + +# Toggles pairable state +toggle_pairable() { + if pairable_on; then + bluetoothctl pairable off + show_menu + else + bluetoothctl pairable on + show_menu + fi +} + +# Checks if controller is discoverable by other devices +discoverable_on() { + if bluetoothctl show | grep -F -q "Discoverable: yes"; then + echo "Discoverable: on" + return 0 + else + echo "Discoverable: off" + return 1 + fi +} + +# Toggles discoverable state +toggle_discoverable() { + if discoverable_on; then + bluetoothctl discoverable off + show_menu + else + bluetoothctl discoverable on + show_menu + fi +} + +# Checks if a device is connected +device_connected() { + device_info=$(bluetoothctl info "$1") + if echo "$device_info" | grep -F -q "Connected: yes"; then + return 0 + else + return 1 + fi +} + +# Toggles device connection +toggle_connection() { + if device_connected "$1"; then + bluetoothctl disconnect "$1" + # device_menu "$device" + else + bluetoothctl connect "$1" + # device_menu "$device" + fi +} + +# Checks if a device is paired +device_paired() { + device_info=$(bluetoothctl info "$1") + if echo "$device_info" | grep -F -q "Paired: yes"; then + echo "Paired: yes" + return 0 + else + echo "Paired: no" + return 1 + fi +} + +# Toggles device paired state +toggle_paired() { + if device_paired "$1"; then + bluetoothctl remove "$1" + device_menu "$device" + else + bluetoothctl pair "$1" + device_menu "$device" + fi +} + +# Checks if a device is trusted +device_trusted() { + device_info=$(bluetoothctl info "$1") + if echo "$device_info" | grep -F -q "Trusted: yes"; then + echo "Trusted: yes" + return 0 + else + echo "Trusted: no" + return 1 + fi +} + +# Toggles device connection +toggle_trust() { + if device_trusted "$1"; then + bluetoothctl untrust "$1" + device_menu "$device" + else + bluetoothctl trust "$1" + device_menu "$device" + fi +} + +# Prints a short string with the current bluetooth status +# Useful for status bars like polybar, etc. +print_status() { + if power_on; then + printf '' + + mapfile -t paired_devices < <(bluetoothctl paired-devices | grep -F Device | cut -d ' ' -f 2) + counter=0 + + for device in "${paired_devices[@]}"; do + if device_connected "$device"; then + device_alias="$(bluetoothctl info "$device" | grep -F "Alias" | cut -d ' ' -f 2-)" + + if [ $counter -gt 0 ]; then + printf ", %s" "$device_alias" + else + printf " %s" "$device_alias" + fi + + ((counter++)) + fi + done + printf "\n" + else + echo "" + fi +} + +# A submenu for a specific device that allows connecting, pairing, and trusting +device_menu() { + device=$1 + + # Get device name and mac address + device_name="$(echo "$device" | cut -d ' ' -f 3-)" + mac="$(echo "$device" | cut -d ' ' -f 2)" + + # Build options + if device_connected "$mac"; then + connected="Connected: yes" + else + connected="Connected: no" + fi + paired=$(device_paired "$mac") + trusted=$(device_trusted "$mac") + options="$connected\n$paired\n$trusted\n$divider\n$goback\nExit" + + # Open dmenu menu, read chosen option + chosen="$(echo -e "$options" | run_dmenu "$device_name")" + + # Match chosen option to command + case $chosen in + "" | "$divider") + echo "No option chosen." + ;; + "$connected") + toggle_connection "$mac" + ;; + "$paired") + toggle_paired "$mac" + ;; + "$trusted") + toggle_trust "$mac" + ;; + "$goback") + show_menu + ;; + esac +} + +# Opens a dmenu menu with current bluetooth status and options to connect +show_menu() { + # Get menu options + if power_on; then + power="Power: on" + + # Human-readable names of devices, one per line + # If scan is off, will only list paired devices + devices=$(bluetoothctl devices | grep -F Device | cut -d ' ' -f 3-) + + # Get controller flags + scan=$(scan_on) + pairable=$(pairable_on) + discoverable=$(discoverable_on) + + # Options passed to dmenu + options="$devices\n$divider\n$power\n$scan\n$pairable\n$discoverable\nExit" + else + power="Power: off" + options="$power\nExit" + fi + + # Open dmenu menu, read chosen option + chosen="$(echo -e "$options" | run_dmenu "Bluetooth")" + + # Match chosen option to command + case $chosen in + "" | "$divider") + echo "No option chosen." + ;; + "$power") + toggle_power + ;; + "$scan") + toggle_scan + ;; + "$discoverable") + toggle_discoverable + ;; + "$pairable") + toggle_pairable + ;; + *) + device=$(bluetoothctl devices | grep -F "$chosen") + # Open a submenu if a device is selected + if [[ $device ]]; then device_menu "$device"; fi + ;; + esac +} + +original_args=("$@") + +# dmenu command to pipe into. Extra arguments to dmenu-bluetooth are passed through to dmenu. This +# allows the user to set fonts, sizes, colours, etc. +run_dmenu() { + dmenu "${original_args[@]}" -i -p "$1" +} + +case "$1" in + --status) + print_status + ;; + *) + show_menu + ;; +esac diff --git a/scripts/dmenumount b/scripts/dmenumount index 59865de..8cf4a6b 100755 --- a/scripts/dmenumount +++ b/scripts/dmenumount @@ -1,5 +1,6 @@ -#!/bin/sh -x +#!/bin/sh +# Gives a dmenu prompt to mount unmounted drives and Android phones. If # they're in /etc/fstab, they'll be mounted automatically. Otherwise, you'll # be prompted to give a mountpoint from already existsing directories. If you # input a novel directory, it will prompt you to create that directory. @@ -26,18 +27,18 @@ mountusb() { \ "vfat") sudo -A mount -t vfat "$chosen" "$mp" -o rw,umask=0000;; "exfat") sudo -A mount "$chosen" "$mp" -o uid="$(id -u)",gid="$(id -g)";; *) sudo -A mount "$chosen" "$mp"; user="$(whoami)"; ug="$(groups | awk '{print $1}')"; sudo -A chown "$user":"$ug" "$mp";; - esac - notify-send "💻 USB mounting" "$chosen mounted to $mp." + esac && notify-send "💻 USB mounting" "$chosen mounted to $mp." || + notify-send "💻 Drive failed to mount." "Probably a permissions issue or drive is already mounted." } mountandroid() { \ chosen="$(echo "$anddrives" | dmenu -i -p "Which Android device?")" || exit 1 chosen="$(echo "$chosen" | cut -d : -f 1)" getmount "$HOME -maxdepth 3 -type d" - simple-mtpfs --device "$chosen" "$mp" echo "OK" | dmenu -i -p "Tap Allow on your phone if it asks for permission and then press enter" || exit 1 - simple-mtpfs --device "$chosen" "$mp" - notify-send "🤖 Android Mounting" "Android device mounted to $mp." + simple-mtpfs --device "$chosen" "$mp" && + notify-send "🤖 Android Mounting" "Android device mounted to $mp." || + notify-send "🤖 Android failed mounting." "Probably a permissions issue or phone is already mounted." } asktype() { \ diff --git a/scripts/dmenupass b/scripts/dmenupass index b8e2f1b..2c14e6f 100755 --- a/scripts/dmenupass +++ b/scripts/dmenupass @@ -3,4 +3,4 @@ # This script is the SUDO_ASKPASS variable, meaning that it will be used as a # password prompt if needed. -dmenu -P -p "$1" <&- && echo +dmenu -fn Monospace-18 -P -p "$1" <&- && echo diff --git a/scripts/dmenuumount b/scripts/dmenuumount index 946d12c..656d1f1 100755 --- a/scripts/dmenuumount +++ b/scripts/dmenuumount @@ -4,41 +4,18 @@ # Provides you with mounted partitions, select one to unmount. # Drives mounted at /, /boot and /home will not be options to unmount. -unmountusb() { - [ -z "$drives" ] && exit - chosen="$(echo "$drives" | dmenu -i -p "Unmount which drive?")" || exit 1 - chosen="$(echo "$chosen" | awk '{print $1}')" - [ -z "$chosen" ] && exit - sudo -A umount "$chosen" && notify-send "💻 USB unmounting" "$chosen unmounted." - } +drives="$(lsblk -nrpo "name,type,size,mountpoint,label" | awk -F':' '{gsub(/ /,":")}$4!~/\/boot|\/efi|\/home$|SWAP/&&length($4)>1{printf "%s (%s) %s\n",$4,$3,$5}'; awk '/simple-mtpfs/ { print "📱", $2; }' /etc/mtab)" -unmountandroid() { \ - chosen="$(awk '/simple-mtpfs/ {print $2}' /etc/mtab | dmenu -i -p "Unmount which device?")" || exit 1 - [ -z "$chosen" ] && exit - sudo -A umount -l "$chosen" && notify-send "🤖 Android unmounting" "$chosen unmounted." - } +chosen="$(echo "$drives" | dmenu -i -p "Unmount which drive?")" || exit 1 -asktype() { \ - choice="$(printf "USB\\nAndroid" | dmenu -i -p "Unmount a USB drive or Android device?")" || exit 1 - case "$choice" in - USB) unmountusb ;; - Android) unmountandroid ;; - esac - } - -drives=$(lsblk -nrpo "name,type,size,mountpoint,label" | awk -F':' '{gsub(/ /,":")}$4!~/\/boot|\/efi|\/home$|SWAP/&&length($4)>1{printf "%s (%s) %s\n",$4,$3,$5}') - -if ! grep simple-mtpfs /etc/mtab; then - [ -z "$drives" ] && echo "No drives to unmount." && exit - echo "Unmountable USB drive detected." - unmountusb -else - if [ -z "$drives" ] - then - echo "Unmountable Android device detected." - unmountandroid - else - echo "Unmountable USB drive(s) and Android device(s) detected." - asktype - fi -fi +case "$chosen" in + 📱*) + chosen="${chosen#📱 }" + sudo -A umount -l "$chosen" + ;; + *) + chosen="${chosen% (*}" + sudo -A umount -l "$chosen" + ;; +esac && notify-send "🖥️ Drive unmounted." "$chosen successfully unmounted." || + notify-send "🖥️ Drive failed to unmount." "Possibly a permissions or I/O issue." diff --git a/scripts/networkmanager_dmenu b/scripts/networkmanager_dmenu new file mode 100644 index 0000000..137c968 --- /dev/null +++ b/scripts/networkmanager_dmenu @@ -0,0 +1,924 @@ +#!/usr/bin/env python3 +# encoding:utf8 +"""NetworkManager command line dmenu script. + +To add new connections or enable/disable networking requires policykit +permissions setup per: +https://wiki.archlinux.org/index.php/NetworkManager#Set_up_PolicyKit_permissions + +OR running the script as root + +Add dmenu options and default terminal if desired to +~/.config/networkmanager-dmenu/config.ini + +""" +import pathlib +import struct +import configparser +import locale +import os +from os.path import expanduser +import shlex +from shutil import which +import sys +from time import sleep +import uuid +import subprocess + +# pylint: disable=import-error +import gi +gi.require_version('NM', '1.0') +from gi.repository import GLib, NM # noqa pylint: disable=wrong-import-position +# pylint: enable=import-error + +ENV = os.environ.copy() +ENV['LC_ALL'] = 'C' +ENC = locale.getpreferredencoding() + +CONF = configparser.ConfigParser() +CONF.read(expanduser("~/.config/networkmanager-dmenu/config.ini")) + + +def cli_args(): + """ Don't override dmenu_cmd function arguments with CLI args. Removes -l + and -p if those are passed on the command line. + + Exception: if -l is passed and dmenu_command is not defined, assume that the + user wants to switch dmenu to the vertical layout and include -l. + + Returns: List of additional CLI arguments + + """ + args = sys.argv[1:] + cmd = CONF.get('dmenu', 'dmenu_command', fallback=False) + if "-l" in args or "-p" in args: + for nope in ['-l', '-p'] if cmd is not False else ['-p']: + try: + nope_idx = args.index(nope) + del args[nope_idx] + del args[nope_idx] + except ValueError: + pass + return args + + +def dmenu_pass(command, color): + """Check if dmenu passphrase patch is applied and return the correct command + line arg list + + Args: command - string + color - obscure color string + Returns: list or None + + """ + if command != 'dmenu': + return None + try: + # Check for dmenu password patch + dm_patch = b'P' in subprocess.run(["dmenu", "-h"], + capture_output=True, + check=False).stderr + except FileNotFoundError: + dm_patch = False + return ["-P"] if dm_patch else ["-nb", color, "-nf", color] + + +def dmenu_cmd(num_lines, prompt="Networks", active_lines=None): + """Parse config.ini for menu options + + Args: args - num_lines: number of lines to display + prompt: prompt to show + active_lines: list of line numbers to tag as active + Returns: command invocation (as a list of strings) for example + ["dmenu", "-l", "", "-p", "", "-i"] + + """ + # Create command string + commands = {"dmenu": ["-p", str(prompt)], + "rofi": ["-dmenu", "-p", str(prompt), "-l", str(num_lines)], + "bemenu": ["-p", str(prompt)], + "wofi": ["-p", str(prompt)]} + command = shlex.split(CONF.get('dmenu', 'dmenu_command', fallback="dmenu")) + command.extend(cli_args()) + command.extend(commands.get(command[0], [])) + # Rofi Highlighting + rofi_highlight = CONF.getboolean('dmenu', 'rofi_highlight', fallback=False) + if rofi_highlight is True and command[0] == "rofi" and active_lines: + command.extend(["-a", ",".join([str(num) for num in active_lines])]) + # Passphrase prompts + obscure = CONF.getboolean('dmenu_passphrase', 'obscure', fallback=False) + if prompt == "Passphrase" and obscure is True: + obscure_color = CONF.get('dmenu_passphrase', 'obscure_color', fallback='#222222') + pass_prompts = {"dmenu": dmenu_pass(command[0], obscure_color), + "rofi": ['-password'], + "bemenu": ['-x'], + "wofi": ['-P']} + command.extend(pass_prompts.get(command[0], [])) + return command + + +def choose_adapter(client): + """If there is more than one wifi adapter installed, ask which one to use + + """ + devices = client.get_devices() + devices = [i for i in devices if i.get_device_type() == NM.DeviceType.WIFI] + if not devices: + return None + if len(devices) == 1: + return devices[0] + device_names = "\n".join([d.get_iface() for d in devices]) + sel = subprocess.run(dmenu_cmd(len(devices), "CHOOSE ADAPTER:"), + capture_output=True, + check=False, + env=ENV, + input=device_names, + encoding=ENC).stdout + if not sel.strip(): + sys.exit() + devices = [i for i in devices if i.get_iface() == sel.strip()] + assert len(devices) == 1 + return devices[0] + + +def is_installed(cmd): + """Check if a utility is installed""" + return which(cmd) is not None + + +def bluetooth_get_enabled(): + """Check if bluetooth is enabled via rfkill. + + Returns None if no bluetooth device was found. + """ + # See https://www.kernel.org/doc/Documentation/ABI/stable/sysfs-class-rfkill + for path in pathlib.Path('/sys/class/rfkill/').glob('rfkill*'): + if (path / 'type').read_text().strip() == 'bluetooth': + return (path / 'soft').read_text().strip() == '0' + return None + + +def create_other_actions(client): + """Return list of other actions that can be taken + + """ + networking_enabled = client.networking_get_enabled() + networking_action = "Disable" if networking_enabled else "Enable" + + wifi_enabled = client.wireless_get_enabled() + wifi_action = "Disable" if wifi_enabled else "Enable" + + bluetooth_enabled = bluetooth_get_enabled() + bluetooth_action = "Disable" if bluetooth_enabled else "Enable" + + actions = [Action(f"{wifi_action} Wifi", toggle_wifi, + not wifi_enabled), + Action(f"{networking_action} Networking", + toggle_networking, not networking_enabled)] + if bluetooth_enabled is not None: + actions.append(Action(f"{bluetooth_action} Bluetooth", + toggle_bluetooth, not bluetooth_enabled)) + actions += [Action("Launch Connection Manager", launch_connection_editor), + Action("Delete a Connection", delete_connection)] + if wifi_enabled: + actions.append(Action("Rescan Wifi Networks", rescan_wifi)) + return actions + + +def rescan_wifi(): + """ + Rescan Wifi Access Points + """ + delay = CONF.getint('nmdm', 'rescan_delay', fallback=5) + for dev in CLIENT.get_devices(): + if gi.repository.NM.DeviceWifi == type(dev): + try: + dev.request_scan_async(None, rescan_cb, None) + LOOP.run() + sleep(delay) + notify("Wifi scan complete") + main() + except gi.repository.GLib.Error as err: + # Too frequent rescan error + notify("Wifi rescan failed", urgency="critical") + if not err.code == 6: # pylint: disable=no-member + raise err + + +def rescan_cb(dev, res, data): + """Callback for rescan_wifi. Just for notifications + + """ + if dev.request_scan_finish(res) is True: + notify("Wifi scan running...") + else: + notify("Wifi scan failed", urgency="critical") + LOOP.quit() + + +def ssid_to_utf8(nm_ap): + """ Convert binary ssid to utf-8 """ + ssid = nm_ap.get_ssid() + if not ssid: + return "" + ret = NM.utils_ssid_to_utf8(ssid.get_data()) + return ret + + +def prompt_saved(saved_cons): + """Prompt for a saved connection.""" + actions = create_saved_actions(saved_cons) + sel = get_selection(actions) + sel() + + +def ap_security(nm_ap): + """Parse the security flags to return a string with 'WPA2', etc. """ + flags = nm_ap.get_flags() + wpa_flags = nm_ap.get_wpa_flags() + rsn_flags = nm_ap.get_rsn_flags() + sec_str = "" + if ((flags & getattr(NM, '80211ApFlags').PRIVACY) and + (wpa_flags == 0) and (rsn_flags == 0)): + sec_str = " WEP" + if wpa_flags: + sec_str = " WPA1" + if rsn_flags & getattr(NM, '80211ApSecurityFlags').KEY_MGMT_PSK: + sec_str += " WPA2" + if rsn_flags & getattr(NM, '80211ApSecurityFlags').KEY_MGMT_SAE: + sec_str += " WPA3" + if ((wpa_flags & getattr(NM, '80211ApSecurityFlags').KEY_MGMT_802_1X) or + (rsn_flags & getattr(NM, '80211ApSecurityFlags').KEY_MGMT_802_1X)): + sec_str += " 802.1X" + if ((wpa_flags & getattr(NM, '80211ApSecurityFlags').KEY_MGMT_OWE) or + (rsn_flags & getattr(NM, '80211ApSecurityFlags').KEY_MGMT_OWE)): + sec_str += " OWE" + + # If there is no security use "--" + if sec_str == "": + sec_str = "--" + return sec_str.lstrip() + + +class Action(): # pylint: disable=too-few-public-methods + """Helper class to execute functions from a string variable""" + def __init__(self, + name, + func, + args=None, + active=False): + self.name = name + self.func = func + self.is_active = active + if args is None: + self.args = None + elif isinstance(args, list): + self.args = args + else: + self.args = [args] + + def __str__(self): + return self.name + + def __call__(self): + if self.args is None: + self.func() + else: + self.func(*self.args) + + +def conn_matches_adapter(conn, adapter): + """Return True if the connection is applicable for the given adapter. + + There seem to be two ways for a connection specify what interface it belongs + to: + + - By setting 'mac-address' in [wifi] to the adapter's MAC + - By setting 'interface-name` in [connection] to the adapter's name. + + Depending on how the connection was added, it seems like either + 'mac-address', 'interface-name' or neither of both is set. + """ + # [wifi] mac-address + setting_wireless = conn.get_setting_wireless() + mac = setting_wireless.get_mac_address() + if mac is not None: + return mac == adapter.get_permanent_hw_address() + + # [connection] interface-name + setting_connection = conn.get_setting_connection() + interface = setting_connection.get_interface_name() + if interface is not None: + return interface == adapter.get_iface() + + # Neither is set, let's assume this connection is for multiple/all adapters. + return True + + +def process_ap(nm_ap, is_active, adapter): + """Activate/Deactivate a connection and get password if required""" + if is_active: + CLIENT.deactivate_connection_async(nm_ap, None, deactivate_cb, nm_ap) + LOOP.run() + else: + conns_cur = [i for i in CONNS if + i.get_setting_wireless() is not None and + conn_matches_adapter(i, adapter)] + con = nm_ap.filter_connections(conns_cur) + if len(con) > 1: + raise ValueError("There are multiple connections possible") + + if len(con) == 1: + CLIENT.activate_connection_async(con[0], adapter, nm_ap.get_path(), + None, activate_cb, nm_ap) + LOOP.run() + else: + if ap_security(nm_ap) != "--": + password = get_passphrase() + else: + password = "" + set_new_connection(nm_ap, password, adapter) + + +def activate_cb(dev, res, data): + """Notification if activate connection completed successfully + + """ + try: + conn = dev.activate_connection_finish(res) + except GLib.Error: + conn = None + if conn is not None: + notify(f"Activated {conn.get_id()}") + else: + notify(f"Problem activating {data.get_id()}", urgency="critical") + LOOP.quit() + + +def deactivate_cb(dev, res, data): + """Notification if deactivate connection completed successfully + + """ + if dev.deactivate_connection_finish(res) is True: + notify(f"Deactivated {data.get_id()}") + else: + notify(f"Problem deactivating {data.get_id()}", urgency="critical") + LOOP.quit() + + +def process_vpngsm(con, activate): + """Activate/deactive VPN or GSM connections""" + if activate: + CLIENT.activate_connection_async(con, None, None, + None, activate_cb, con) + else: + CLIENT.deactivate_connection_async(con, None, deactivate_cb, con) + LOOP.run() + + +def create_ap_actions(aps, active_ap, active_connection, adapter): # noqa pylint: disable=too-many-locals,line-too-long + """For each AP in a list, create the string and its attached function + (activate/deactivate) + + """ + active_ap_bssid = active_ap.get_bssid() if active_ap is not None else "" + + names = [ssid_to_utf8(ap) for ap in aps] + max_len_name = max([len(name) for name in names]) if names else 0 + secs = [ap_security(ap) for ap in aps] + max_len_sec = max([len(sec) for sec in secs]) if secs else 0 + + ap_actions = [] + + for nm_ap, name, sec in zip(aps, names, secs): + bars = NM.utils_wifi_strength_bars(nm_ap.get_strength()) + wifi_chars = CONF.get("dmenu", "wifi_chars", fallback=False) + if wifi_chars: + bars = "".join([wifi_chars[i] for i, j in enumerate(bars) if j == '*']) + is_active = nm_ap.get_bssid() == active_ap_bssid + compact = CONF.getboolean("dmenu", "compact", fallback=False) + if compact: + action_name = f"{name} {sec} {bars}" + else: + action_name = f"{name:<{max_len_name}s} {sec:<{max_len_sec}s} {bars:>4}" + if is_active: + ap_actions.append(Action(action_name, process_ap, + [active_connection, True, adapter], + active=True)) + else: + ap_actions.append(Action(action_name, process_ap, + [nm_ap, False, adapter])) + return ap_actions + + +def create_vpn_actions(vpns, active): + """Create the list of strings to display with associated function + (activate/deactivate) for VPN connections. + + """ + active_vpns = [i for i in active if i.get_vpn()] + return _create_vpngsm_actions(vpns, active_vpns, "VPN") + + +def create_wireguard_actions(wgs, active): + """Create the list of strings to display with associated function + (activate/deactivate) for Wireguard connections. + + """ + active_wgs = [i for i in active if i.get_connection_type() == "wireguard"] + return _create_vpngsm_actions(wgs, active_wgs, "Wireguard") + + +def create_eth_actions(eths, active): + """Create the list of strings to display with associated function + (activate/deactivate) for Ethernet connections. + + """ + active_eths = [i for i in active if 'ethernet' in i.get_connection_type()] + return _create_vpngsm_actions(eths, active_eths, "Eth") + + +def create_gsm_actions(gsms, active): + """Create the list of strings to display with associated function + (activate/deactivate) GSM connections.""" + active_gsms = [i for i in active if + i.get_connection() is not None and + i.get_connection().is_type(NM.SETTING_GSM_SETTING_NAME)] + return _create_vpngsm_actions(gsms, active_gsms, "GSM") + + +def create_blue_actions(blues, active): + """Create the list of strings to display with associated function + (activate/deactivate) Bluetooth connections.""" + active_blues = [i for i in active if + i.get_connection() is not None and + i.get_connection().is_type(NM.SETTING_BLUETOOTH_SETTING_NAME)] + return _create_vpngsm_actions(blues, active_blues, "Bluetooth") + + +def create_saved_actions(saved): + """Create the list of strings to display with associated function + (activate/deactivate) for VPN connections. + + """ + return _create_vpngsm_actions(saved, [], "SAVED") + + +def _create_vpngsm_actions(cons, active_cons, label): + active_con_ids = [a.get_id() for a in active_cons] + actions = [] + for con in cons: + is_active = con.get_id() in active_con_ids + action_name = f"{con.get_id()}:{label}" + if is_active: + active_connection = [a for a in active_cons + if a.get_id() == con.get_id()] + if len(active_connection) != 1: + raise ValueError(f"Multiple active connections match {con.get_id()}") + active_connection = active_connection[0] + + actions.append(Action(action_name, process_vpngsm, + [active_connection, False], active=True)) + else: + actions.append(Action(action_name, process_vpngsm, + [con, True])) + return actions + + +def create_wwan_actions(client): + """Create WWWAN actions + + """ + wwan_enabled = client.wwan_get_enabled() + wwan_action = "Disable" if wwan_enabled else "Enable" + return [Action(f"{wwan_action} WWAN", toggle_wwan, not wwan_enabled)] + + +def combine_actions(eths, aps, vpns, wgs, gsms, blues, wwan, others, saved): + # pylint: disable=too-many-arguments + """Combine all given actions into a list of actions. + + Args: args - eths: list of Actions + aps: list of Actions + vpns: list of Actions + gsms: list of Actions + blues: list of Actions + wwan: list of Actions + others: list of Actions + """ + compact = CONF.getboolean("dmenu", "compact", fallback=False) + empty_action = [Action('', None)] if not compact else [] + all_actions = [] + all_actions += eths + empty_action if eths else [] + all_actions += aps + empty_action if aps else [] + all_actions += vpns + empty_action if vpns else [] + all_actions += wgs + empty_action if wgs else [] + all_actions += gsms + empty_action if (gsms and wwan) else [] + all_actions += blues + empty_action if blues else [] + all_actions += wwan + empty_action if wwan else [] + all_actions += others + empty_action if others else [] + all_actions += saved + empty_action if saved else [] + return all_actions + + +def get_selection(all_actions): + """Spawn dmenu for selection and execute the associated action.""" + rofi_highlight = CONF.getboolean('dmenu', 'rofi_highlight', fallback=False) + inp = [] + + if rofi_highlight is True: + inp = [str(action) for action in all_actions] + else: + inp = [('== ' if action.is_active else ' ') + str(action) + for action in all_actions] + active_lines = [index for index, action in enumerate(all_actions) + if action.is_active] + + command = dmenu_cmd(len(inp), active_lines=active_lines) + sel = subprocess.run(command, + capture_output=True, + check=False, + input="\n".join(inp), + encoding=ENC, + env=ENV).stdout + + if not sel.rstrip(): + sys.exit() + + if rofi_highlight is False: + action = [i for i in all_actions + if ((str(i).strip() == str(sel.strip()) + and not i.is_active) or + ('== ' + str(i) == str(sel.rstrip('\n')) + and i.is_active))] + else: + action = [i for i in all_actions if str(i).strip() == sel.strip()] + assert len(action) == 1, f"Selection was ambiguous: '{str(sel.strip())}'" + return action[0] + + +def toggle_networking(enable): + """Enable/disable networking + + Args: enable - boolean + + """ + toggle = GLib.Variant.new_tuple(GLib.Variant.new_boolean(enable)) + try: + CLIENT.dbus_call(NM.DBUS_PATH, NM.DBUS_INTERFACE, "Enable", toggle, + None, -1, None, None, None) + except AttributeError: + # Workaround for older versions of python-gobject + CLIENT.networking_set_enabled(enable) + notify(f"Networking {'enabled' if enable is True else 'disabled'}") + + +def toggle_wifi(enable): + """Enable/disable Wifi + + Args: enable - boolean + + """ + toggle = GLib.Variant.new_boolean(enable) + try: + CLIENT.dbus_set_property(NM.DBUS_PATH, NM.DBUS_INTERFACE, "WirelessEnabled", toggle, + -1, None, None, None) + except AttributeError: + # Workaround for older versions of python-gobject + CLIENT.wireless_set_enabled(enable) + notify(f"Wifi {'enabled' if enable is True else 'disabled'}") + + +def toggle_wwan(enable): + """Enable/disable WWAN + + Args: enable - boolean + + """ + toggle = GLib.Variant.new_boolean(enable) + try: + CLIENT.dbus_set_property(NM.DBUS_PATH, NM.DBUS_INTERFACE, "WwanEnabled", toggle, + -1, None, None, None) + except AttributeError: + # Workaround for older versions of python-gobject + CLIENT.wwan_set_enabled(enable) + notify(f"Wwan {'enabled' if enable is True else 'disabled'}") + + +def toggle_bluetooth(enable): + """Enable/disable Bluetooth + + Args: enable - boolean + + References: + https://github.com/blueman-project/blueman/blob/master/blueman/plugins/mechanism/RfKill.py + https://www.kernel.org/doc/html/latest/driver-api/rfkill.html + https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/include/uapi/linux/rfkill.h?h=v5.8.9 + + """ + type_bluetooth = 2 + op_change_all = 3 + idx = 0 + soft_state = 0 if enable else 1 + hard_state = 0 + + data = struct.pack("IBBBB", idx, type_bluetooth, op_change_all, + soft_state, hard_state) + + try: + with open('/dev/rfkill', 'r+b', buffering=0) as rff: + rff.write(data) + except PermissionError: + notify("Lacking permission to write to /dev/rfkill.", + "Check README for configuration options.", + urgency="critical") + else: + notify(f"Bluetooth {'enabled' if enable else 'disabled'}") + + +def launch_connection_editor(): + """Launch nmtui or the gui nm-connection-editor + + """ + terminal = CONF.get("editor", "terminal", fallback="xterm") + gui_if_available = CONF.getboolean("editor", "gui_if_available", fallback=True) + guis = ["gnome-control-center", "nm-connection-editor"] + if gui_if_available is True: + for gui in guis: + if is_installed(gui): + subprocess.run(gui, check=False) + return + if is_installed("nmtui"): + subprocess.run([terminal, "-e", "nmtui"], check=False) + return + notify("No network connection editor installed", urgency="critical") + + +def get_passphrase(): + """Get a password + + Returns: string + + """ + pinentry = CONF.get("dmenu", "pinentry", fallback=None) + if pinentry: + pin = "" + out = subprocess.run(pinentry, + capture_output=True, + check=False, + encoding=ENC, + input='setdesc Get network password\ngetpin\n').stdout + if out: + res = out.split("\n")[2] + if res.startswith("D "): + pin = res.split("D ")[1] + return pin + return subprocess.run(dmenu_cmd(0, "Passphrase"), + stdin=subprocess.DEVNULL, + capture_output=True, + check=False, + encoding=ENC).stdout + + +def delete_connection(): + """Display list of NM connections and delete the selected one + + """ + conn_acts = [Action(i.get_id(), i.delete_async, args=[None, delete_cb, None]) for i in CONNS] + conn_names = "\n".join([str(i) for i in conn_acts]) + sel = subprocess.run(dmenu_cmd(len(conn_acts), "CHOOSE CONNECTION TO DELETE:"), + capture_output=True, + check=False, + input=conn_names, + encoding=ENC, + env=ENV).stdout + if not sel.strip(): + sys.exit() + action = [i for i in conn_acts if str(i) == sel.rstrip("\n")] + assert len(action) == 1, f"Selection was ambiguous: {str(sel)}" + action[0]() + LOOP.run() + + +def delete_cb(dev, res, data): + """Notification if delete completed successfully + + """ + if dev.delete_finish(res) is True: + notify(f"Deleted {dev.get_id()}") + else: + notify(f"Problem deleting {dev.get_id()}", urgency="critical") + LOOP.quit() + + +def set_new_connection(nm_ap, nm_pw, adapter): + """Setup a new NetworkManager connection + + Args: ap - NM.AccessPoint + pw - string + + """ + nm_pw = str(nm_pw).strip() + profile = create_wifi_profile(nm_ap, nm_pw, adapter) + CLIENT.add_and_activate_connection_async(profile, adapter, nm_ap.get_path(), + None, verify_conn, profile) + LOOP.run() + + +def create_wifi_profile(nm_ap, password, adapter): + # pylint: disable=line-too-long + # noqa From https://cgit.freedesktop.org/NetworkManager/NetworkManager/tree/examples/python/gi/add_connection.py + # noqa and https://cgit.freedesktop.org/NetworkManager/NetworkManager/tree/examples/python/dbus/add-wifi-psk-connection.py + # pylint: enable=line-too-long + """Create the NM profile given the AP and passphrase""" + ap_sec = ap_security(nm_ap) + profile = NM.SimpleConnection.new() + + s_con = NM.SettingConnection.new() + s_con.set_property(NM.SETTING_CONNECTION_ID, ssid_to_utf8(nm_ap)) + s_con.set_property(NM.SETTING_CONNECTION_UUID, str(uuid.uuid4())) + s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") + profile.add_setting(s_con) + + s_wifi = NM.SettingWireless.new() + s_wifi.set_property(NM.SETTING_WIRELESS_SSID, nm_ap.get_ssid()) + s_wifi.set_property(NM.SETTING_WIRELESS_MODE, 'infrastructure') + s_wifi.set_property(NM.SETTING_WIRELESS_MAC_ADDRESS, adapter.get_permanent_hw_address()) + profile.add_setting(s_wifi) + + s_ip4 = NM.SettingIP4Config.new() + s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto") + profile.add_setting(s_ip4) + + s_ip6 = NM.SettingIP6Config.new() + s_ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto") + profile.add_setting(s_ip6) + + if ap_sec != "--": + s_wifi_sec = NM.SettingWirelessSecurity.new() + if "WPA" in ap_sec: + if "WPA3" in ap_sec: + s_wifi_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, + "sae") + else: + s_wifi_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, + "wpa-psk") + s_wifi_sec.set_property(NM.SETTING_WIRELESS_SECURITY_AUTH_ALG, + "open") + s_wifi_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password) + elif "WEP" in ap_sec: + s_wifi_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, + "None") + s_wifi_sec.set_property(NM.SETTING_WIRELESS_SECURITY_WEP_KEY_TYPE, + NM.WepKeyType.PASSPHRASE) + s_wifi_sec.set_wep_key(0, password) + profile.add_setting(s_wifi_sec) + + return profile + + +def verify_conn(client, result, data): + """Callback function for add_and_activate_connection_async + + Check if connection completes successfully. Delete the connection if there + is an error. + + """ + try: + act_conn = client.add_and_activate_connection_finish(result) + conn = act_conn.get_connection() + if not all([conn.verify(), + conn.verify_secrets(), + data.verify(), + data.verify_secrets()]): + raise GLib.Error + notify(f"Added {conn.get_id()}") + except GLib.Error: + try: + notify(f"Connection to {conn.get_id()} failed", + urgency="critical") + conn.delete_async(None, None, None) + except UnboundLocalError: + pass + finally: + LOOP.quit() + + +def create_ap_list(adapter, active_connections): + """Generate list of access points. Remove duplicate APs , keeping strongest + ones and the active AP + + Args: adapter + active_connections - list of all active connections + Returns: aps - list of access points + active_ap - active AP + active_ap_con - active Connection + adapter + + """ + aps = [] + ap_names = [] + active_ap = adapter.get_active_access_point() + aps_all = sorted(adapter.get_access_points(), + key=lambda a: a.get_strength(), reverse=True) + conns_cur = [i for i in CONNS if + i.get_setting_wireless() is not None and + conn_matches_adapter(i, adapter)] + try: + ap_conns = active_ap.filter_connections(conns_cur) + active_ap_name = ssid_to_utf8(active_ap) + active_ap_con = [active_conn for active_conn in active_connections + if active_conn.get_connection() in ap_conns] + except AttributeError: + active_ap_name = None + active_ap_con = [] + if len(active_ap_con) > 1: + raise ValueError("Multiple connection profiles match" + " the wireless AP") + active_ap_con = active_ap_con[0] if active_ap_con else None + for nm_ap in aps_all: + ap_name = ssid_to_utf8(nm_ap) + if nm_ap != active_ap and ap_name == active_ap_name: + # Skip adding AP if it's not active but same name as active AP + continue + if ap_name not in ap_names: + ap_names.append(ap_name) + aps.append(nm_ap) + return aps, active_ap, active_ap_con, adapter + + +def notify(message, details=None, urgency="low"): + """Use notify-send if available for notifications + + """ + delay = CONF.getint('nmdm', 'rescan_delay', fallback=5) + args = ["-u", urgency, "-a", "networkmanager-dmenu", + "-t", str(delay * 1000), message] + if details is not None: + args.append(details) + if is_installed("notify-send"): + subprocess.run(["notify-send"] + args, check=False) + + +def run(): # pylint: disable=too-many-locals + """Main script entrypoint""" + active = CLIENT.get_active_connections() + adapter = choose_adapter(CLIENT) + if adapter: + ap_actions = create_ap_actions(*create_ap_list(adapter, active)) + else: + ap_actions = [] + + vpns = [i for i in CONNS if i.is_type(NM.SETTING_VPN_SETTING_NAME)] + try: + wgs = [i for i in CONNS if i.is_type(NM.SETTING_WIREGUARD_SETTING_NAME)] + except AttributeError: + # Workaround for older versions of python-gobject with no wireguard support + wgs = [] + eths = [i for i in CONNS if i.is_type(NM.SETTING_WIRED_SETTING_NAME)] + blues = [i for i in CONNS if i.is_type(NM.SETTING_BLUETOOTH_SETTING_NAME)] + + vpn_actions = create_vpn_actions(vpns, active) + wg_actions = create_wireguard_actions(wgs, active) + eth_actions = create_eth_actions(eths, active) + blue_actions = create_blue_actions(blues, active) + other_actions = create_other_actions(CLIENT) + wwan_installed = is_installed("ModemManager") + if wwan_installed: + gsms = [i for i in CONNS if i.is_type(NM.SETTING_GSM_SETTING_NAME)] + gsm_actions = create_gsm_actions(gsms, active) + wwan_actions = create_wwan_actions(CLIENT) + else: + gsm_actions = [] + wwan_actions = [] + + list_saved = CONF.getboolean('dmenu', 'list_saved', fallback=False) + saved_cons = [i for i in CONNS if i not in vpns + wgs + eths + blues] + if list_saved: + saved_actions = create_saved_actions(saved_cons) + else: + saved_actions = [Action("Saved connections", prompt_saved, [saved_cons])] + + actions = combine_actions(eth_actions, ap_actions, vpn_actions, wg_actions, + gsm_actions, blue_actions, wwan_actions, + other_actions, saved_actions) + sel = get_selection(actions) + sel() + + +def main(): + """Main. Enables script to be re-run after a wifi rescan + + """ + global CLIENT, CONNS, LOOP # noqa pylint: disable=global-variable-undefined + CLIENT = NM.Client.new(None) + LOOP = GLib.MainLoop() + CONNS = CLIENT.get_connections() + + run() + + +if __name__ == '__main__': + main() + +# vim: set et ts=4 sw=4 : diff --git a/update-dmenu-bluetooth b/update-dmenu-bluetooth new file mode 100755 index 0000000..f5b1696 --- /dev/null +++ b/update-dmenu-bluetooth @@ -0,0 +1,33 @@ +#!/bin/sh + +echo "This script will clone Layerex's dmenu-bluetooth and copy the scripts from it that I use." +read -r -p "Are you sure you want to continue [Y/n]" input + +case $input in +[nN][oO] | [nN]) + echo "Okay, exiting..." + exit 0 + ;; +*) + printf "\n" + ;; +esac + +git clone https://github.com/Layerex/dmenu-bluetooth + +printf "\nCopying scripts...\n" + +dir="dmenu-bluetooth" + +if [ -d "$dir" ]; then + for script in ./scripts/*; do + if [ -f "./$dir/${script##*/}" ]; then + cp "./$dir/${script##*/}" ./scripts/ -v + fi + done + + printf "\nRemoving %s..." "$dir" + rm -rf "./$dir" && printf "\nUpdate Complete." +else + echo "directory $dir does not exist" +fi diff --git a/update-networkmanager-dmenu b/update-networkmanager-dmenu new file mode 100755 index 0000000..0ee2225 --- /dev/null +++ b/update-networkmanager-dmenu @@ -0,0 +1,33 @@ +#!/bin/sh + +echo "This script will clone firecat53's networkmanager-dmenu and copy the scripts from it that I use." +read -r -p "Are you sure you want to continue [Y/n]" input + +case $input in +[nN][oO] | [nN]) + echo "Okay, exiting..." + exit 0 + ;; +*) + printf "\n" + ;; +esac + +git clone https://github.com/firecat53/networkmanager-dmenu + +printf "\nCopying scripts...\n" + +dir="networkmanager-dmenu" + +if [ -d "$dir" ]; then + for script in ./scripts/*; do + if [ -f "./$dir/${script##*/}" ]; then + cp "./$dir/${script##*/}" ./scripts/ -v + fi + done + + printf "\nRemoving %s..." "$dir" + rm -rf "./$dir" && printf "\nUpdate Complete." +else + echo "directory $dir does not exist" +fi diff --git a/update-voidrice b/update-voidrice index 467bd9e..044d5e9 100755 --- a/update-voidrice +++ b/update-voidrice @@ -1,14 +1,40 @@ -#!/bin/bash +#!/bin/sh -# This script assumes that you are running it in a sibling directory to Luke Smith's voidrice. -# I use some of his scripts and although they don't often update, they are known to. +# I use some of Luke Smiths scripts and although they don't often update, they are known to. # The output is going to include a bunch of copy errors as I'm just copying each file in this directory from his .local/bin back to here, obviously my custom scripts won't exist so they'll fail. -for script in ./scripts/*; do - cp "../voidrice/.local/bin/$script" ./scripts/ -v -done +echo "This script will clone Luke Smith's voidrice and copy the scripts from it that I use." +read -r -p "Are you sure you want to continue [Y/n]" input +case $input in +[nN][oO] | [nN]) + echo "Okay, exiting..." + exit 0 + ;; +*) + printf "\n" + ;; +esac -for cron in ./scripts/cron/*; do - cp "../voidrice/.local/bin/cron/$cron" ./scripts/cron/ -v -done +git clone https://github.com/LukeSmithxyz/voidrice + +printf "\nCopying scripts...\n" + +if [ -d voidrice ]; then + for script in ./scripts/*; do + if [ -f "./voidrice/.local/bin/${script##*/}" ]; then + cp "./voidrice/.local/bin/${script##*/}" ./scripts/ -v + fi + done + + for cron in ./scripts/cron/*; do + if [ -f "./voidrice/.local/bin/${cron##*/}" ]; then + cp "./voidrice/.local/bin/cron/${cron##*/}" ./scripts/cron/ -v + fi + done + + printf "\nRemoving voidrice..." + rm -rf ./voidrice && printf "\nUpdate Complete." +else + echo "directory voidrice does not exist" +fi