Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
2Shirt 2020-01-30 14:09:13 -07:00
commit c1970b8c23
Signed by: 2Shirt
GPG key ID: 152FAC923B0E132C
12 changed files with 107 additions and 51 deletions

View file

@ -2,6 +2,12 @@
# #
## Wizard Kit: TMUX Launcher ## Wizard Kit: TMUX Launcher
# Live macOS env workaround
tmux_args=()
if [[ -e "/.wk-live-macos" ]]; then
tmux_args=(-f "/etc/tmux.conf" -S "$(mktemp).socket")
fi
function ask() { function ask() {
while :; do while :; do
read -p "$1 [Y/N] " -r answer read -p "$1 [Y/N] " -r answer
@ -25,24 +31,24 @@ function launch_in_tmux() {
[[ -n "${TMUX_CMD:-}" ]] || return $(err "Required variable missing (TMUX_CMD)") [[ -n "${TMUX_CMD:-}" ]] || return $(err "Required variable missing (TMUX_CMD)")
# Check for running session # Check for running session
if tmux list-session | grep -q "$SESSION_NAME"; then if tmux "${tmux_args[@]}" list-session | grep -q "$SESSION_NAME"; then
echo "WARNING: tmux session $SESSION_NAME already exists." echo "WARNING: tmux session $SESSION_NAME already exists."
echo "" echo ""
if ask "Connect to current session?"; then if ask "Connect to current session?"; then
if [[ -n "${TMUX:-}" ]]; then if [[ -n "${TMUX:-}" ]]; then
# Running inside TMUX, switch to session # Running inside TMUX, switch to session
tmux switch-client -t "$SESSION_NAME" tmux "${tmux_args[@]}" switch-client -t "$SESSION_NAME"
if ! jobs %% >/dev/null 2>&1; then if ! jobs %% >/dev/null 2>&1; then
# No running jobs, try exiting abandoned tmux session # No running jobs, try exiting abandoned tmux session
exit 0 exit 0
fi fi
else else
# Running outside TMUX, attach to session # Running outside TMUX, attach to session
tmux attach-session -t "$SESSION_NAME" tmux "${tmux_args[@]}" attach-session -t "$SESSION_NAME"
fi fi
return 0 return 0
elif ask "Kill current session and start new session?"; then elif ask "Kill current session and start new session?"; then
tmux kill-session -t "$SESSION_NAME" || \ tmux "${tmux_args[@]}" kill-session -t "$SESSION_NAME" || \
die "Failed to kill session: $SESSION_NAME" die "Failed to kill session: $SESSION_NAME"
else else
echo "Aborted." echo "Aborted."
@ -53,16 +59,16 @@ function launch_in_tmux() {
# Start session # Start session
if [[ -n "${TMUX:-}" ]]; then if [[ -n "${TMUX:-}" ]]; then
# Running inside TMUX, save current session/window names # Running inside TMUX, save current session/window names
ORIGINAL_SESSION_NAME="$(tmux display-message -p '#S')" ORIGINAL_SESSION_NAME="$(tmux "${tmux_args[@]}" display-message -p '#S')"
ORIGINAL_WINDOW_NAME="$(tmux display-message -p '#W')" ORIGINAL_WINDOW_NAME="$(tmux "${tmux_args[@]}" display-message -p '#W')"
tmux rename-session "$SESSION_NAME" tmux "${tmux_args[@]}" rename-session "$SESSION_NAME"
tmux rename-window "$WINDOW_NAME" tmux "${tmux_args[@]}" rename-window "$WINDOW_NAME"
"$TMUX_CMD" "$@" "$TMUX_CMD" "$@"
# Restore previous session/window names # Restore previous session/window names
tmux rename-session "${ORIGINAL_SESSION_NAME}" tmux "${tmux_args[@]}" rename-session "${ORIGINAL_SESSION_NAME}"
tmux rename-window "${ORIGINAL_WINDOW_NAME}" tmux "${tmux_args[@]}" rename-window "${ORIGINAL_WINDOW_NAME}"
else else
# Running outside TMUX, start/attach to session # Running outside TMUX, start/attach to session
tmux new-session -s "$SESSION_NAME" -n "$WINDOW_NAME" "$TMUX_CMD" "$@" tmux "${tmux_args[@]}" new-session -s "$SESSION_NAME" -n "$WINDOW_NAME" "$TMUX_CMD" "$@"
fi fi
} }

View file

@ -3,6 +3,7 @@
import json import json
import logging import logging
import os
import re import re
import subprocess import subprocess
@ -77,6 +78,10 @@ def build_cmd_kwargs(cmd, minimized=False, pipe=True, shell=False, **kwargs):
'shell': shell, 'shell': shell,
} }
# Strip sudo if appropriate
if cmd[0] == 'sudo' and os.name == 'posix' and os.geteuid() == 0:
cmd.pop(0)
# Add additional kwargs if applicable # Add additional kwargs if applicable
for key in 'check cwd encoding errors stderr stdin stdout'.split(): for key in 'check cwd encoding errors stderr stdin stdout'.split():
if key in kwargs: if key in kwargs:

View file

@ -82,7 +82,9 @@ PANE_RATIOS = (
) )
PLATFORM = std.PLATFORM PLATFORM = std.PLATFORM
RECOMMENDED_FSTYPES = re.compile(r'^(ext[234]|ntfs|xfs)$') RECOMMENDED_FSTYPES = re.compile(r'^(ext[234]|ntfs|xfs)$')
RECOMMENDED_MAP_FSTYPES = re.compile(r'^(cifs|ext[234]|ntfs|vfat|xfs)$') RECOMMENDED_MAP_FSTYPES = re.compile(
r'^(apfs|cifs|ext[234]|hfs.?|ntfs|vfat|xfs)$'
)
SETTING_PRESETS = ( SETTING_PRESETS = (
'Default', 'Default',
'Fast', 'Fast',
@ -729,6 +731,8 @@ class State():
self.update_progress_pane('Idle') self.update_progress_pane('Idle')
self.confirm_selections('Start recovery?') self.confirm_selections('Start recovery?')
# TODO: Unmount source and/or destination under macOS
# Prep destination # Prep destination
if self.mode == 'Clone': if self.mode == 'Clone':
self.prep_destination(source_parts, dry_run=docopt_args['--dry-run']) self.prep_destination(source_parts, dry_run=docopt_args['--dry-run'])
@ -1225,7 +1229,6 @@ def build_directory_report(path):
line = f'{path:<{width}}{line}' line = f'{path:<{width}}{line}'
report.append(line) report.append(line)
else: else:
# TODO Get dir details under macOS
report.append(std.color_string('PATH', 'BLUE')) report.append(std.color_string('PATH', 'BLUE'))
report.append(str(path)) report.append(str(path))
@ -1478,8 +1481,11 @@ def fstype_is_ok(path, map_dir=False):
# Get fstype # Get fstype
if PLATFORM == 'Darwin': if PLATFORM == 'Darwin':
# TODO: Determine fstype under macOS try:
pass fstype = get_fstype_macos(path)
except (IndexError, TypeError, ValueError):
# Ignore for now
pass
elif PLATFORM == 'Linux': elif PLATFORM == 'Linux':
cmd = [ cmd = [
'findmnt', 'findmnt',
@ -1546,6 +1552,24 @@ def get_etoc():
return etoc return etoc
def get_fstype_macos(path):
"""Get fstype for path under macOS, returns str.
NOTE: This method is not very effecient.
"""
cmd = ['df', path]
# Get device based on the path
proc = exe.run_program(cmd, check=False)
dev = proc.stdout.splitlines()[1].split()[0]
# Get device details
dev = hw_obj.Disk(dev)
# Done
return dev.details['fstype']
def get_object(path): def get_object(path):
"""Get object based on path, returns obj.""" """Get object based on path, returns obj."""
obj = None obj = None

View file

@ -211,7 +211,11 @@ class State():
) )
else: else:
# No blocking errors encountered, check for minor attribute failures # No blocking errors encountered, check for minor attribute failures
if not disk.check_attributes(only_blocking=False): if ('Disk Attributes' in disk.tests
and not disk.tests['Disk Attributes'].failed
and not disk.check_attributes(only_blocking=False)):
# Mid-diag failure detected
LOG.warning('Disk attributes failure detected during diagnostics')
disk.tests['Disk Attributes'].failed = True disk.tests['Disk Attributes'].failed = True
disk.tests['Disk Attributes'].set_status('Failed') disk.tests['Disk Attributes'].set_status('Failed')
@ -405,7 +409,6 @@ def audio_test():
"""Run an OS-specific audio test.""" """Run an OS-specific audio test."""
if PLATFORM == 'Linux': if PLATFORM == 'Linux':
audio_test_linux() audio_test_linux()
# TODO: Add tests for other OS
def audio_test_linux(): def audio_test_linux():
@ -456,10 +459,10 @@ def build_menu(cli_mode=False, quick_mode=False):
# Compatibility checks # Compatibility checks
if PLATFORM != 'Linux': if PLATFORM != 'Linux':
for name in ('Audio Test', 'Keyboard Test', 'Network Test'): for name in ('Audio Test', 'Keyboard Test'):
menu.actions[name]['Disabled'] = True menu.actions[name]['Disabled'] = True
if PLATFORM not in ('Darwin', 'Linux'): if PLATFORM not in ('Darwin', 'Linux'):
for name in ('Matrix', 'Tubes'): for name in ('Matrix', 'Network Test', 'Tubes'):
menu.actions[name]['Disabled'] = True menu.actions[name]['Disabled'] = True
# Done # Done

View file

@ -321,6 +321,7 @@ class Disk(BaseObj):
self.details['bus'] = str(self.details.get('bus', '???')).upper() self.details['bus'] = str(self.details.get('bus', '???')).upper()
self.details['bus'] = self.details['bus'].replace('IMAGE', 'Image') self.details['bus'] = self.details['bus'].replace('IMAGE', 'Image')
self.details['bus'] = self.details['bus'].replace('NVME', 'NVMe') self.details['bus'] = self.details['bus'].replace('NVME', 'NVMe')
self.details['fstype'] = self.details.get('fstype', 'Unknown')
self.details['log-sec'] = self.details.get('log-sec', 512) self.details['log-sec'] = self.details.get('log-sec', 512)
self.details['model'] = self.details.get('model', 'Unknown Model') self.details['model'] = self.details.get('model', 'Unknown Model')
self.details['name'] = self.details.get('name', self.path) self.details['name'] = self.details.get('name', self.path)
@ -368,13 +369,9 @@ class Disk(BaseObj):
try: try:
details = self.smartctl['ata_smart_data']['self_test'] details = self.smartctl['ata_smart_data']['self_test']
except (KeyError, TypeError): except (KeyError, TypeError):
# Assuming disk lacks SMART support, ignore and return nearly empty dict. # Assuming disk lacks SMART support, ignore and return empty dict.
pass pass
# Ensure status is present even if empty
if 'status' not in details:
details['status'] = {}
# Done # Done
return details return details
@ -385,7 +382,6 @@ class Disk(BaseObj):
aligned = is_4k_aligned_macos(self.details) aligned = is_4k_aligned_macos(self.details)
elif PLATFORM == 'Linux': elif PLATFORM == 'Linux':
aligned = is_4k_aligned_linux(self.path, self.details['phy-sec']) aligned = is_4k_aligned_linux(self.path, self.details['phy-sec'])
#TODO: Add checks for other OS
return aligned return aligned
@ -502,11 +498,11 @@ class Disk(BaseObj):
_f.write(f'{header_str}\nSMART self-test status:\n {status_str}') _f.write(f'{header_str}\nSMART self-test status:\n {status_str}')
# Check if finished # Check if finished
if 'remaining_percent' not in test_details['status']: if 'remaining_percent' not in test_details.get('status', {}):
finished = True finished = True
break break
elif 'remaining_percent' in test_details['status']: elif 'remaining_percent' in test_details.get('status', {}):
started = True started = True
# Check result # Check result
@ -660,12 +656,15 @@ def get_disk_details_macos(path):
dev['label'] = dev.pop('VolumeName', '') dev['label'] = dev.pop('VolumeName', '')
dev['model'] = dev.pop('MediaName', 'Unknown') dev['model'] = dev.pop('MediaName', 'Unknown')
dev['mountpoint'] = dev.pop('MountPoint', '') dev['mountpoint'] = dev.pop('MountPoint', '')
dev['name'] = dev.get('name', str(dev['path']))
dev['phy-sec'] = dev.pop('DeviceBlockSize', 512) dev['phy-sec'] = dev.pop('DeviceBlockSize', 512)
dev['serial'] = get_disk_serial_macos(dev['path']) dev['serial'] = get_disk_serial_macos(dev['path'])
dev['size'] = dev.pop('Size', -1) dev['size'] = dev.pop('Size', -1)
dev['ssd'] = dev.pop('SolidState', False) dev['ssd'] = dev.pop('SolidState', False)
dev['vendor'] = '' dev['vendor'] = ''
if not dev.get('WholeDisk', True): if dev.get('WholeDisk', True):
dev['parent'] = None
else:
dev['parent'] = dev.pop('ParentWholeDisk', None) dev['parent'] = dev.pop('ParentWholeDisk', None)
# Done # Done

View file

@ -1,8 +1,5 @@
"""WizardKit: UFD Functions""" """WizardKit: UFD Functions"""
# vim: sts=2 sw=2 ts=2 # vim: sts=2 sw=2 ts=2
# TODO: Replace some lsblk usage with hw_obj?
# TODO: Reduce imports if possible
# TODO: Needs testing
import logging import logging
import os import os
@ -57,14 +54,11 @@ def build_ufd():
args = docopt(DOCSTRING) args = docopt(DOCSTRING)
log.update_log_path(dest_name='build-ufd', timestamp=True) log.update_log_path(dest_name='build-ufd', timestamp=True)
try_print = std.TryAndPrint() try_print = std.TryAndPrint()
try_print.add_error(FileNotFoundError)
try_print.catch_all = False try_print.catch_all = False
try_print.verbose = True
try_print.indent = 2 try_print.indent = 2
# Check if running with root permissions
if not linux.running_as_root():
std.print_error('This script is meant to be run as root')
std.abort()
# Show header # Show header
std.print_success(KIT_NAME_FULL) std.print_success(KIT_NAME_FULL)
std.print_warning('UFD Build Tool') std.print_warning('UFD Build Tool')
@ -202,6 +196,7 @@ def confirm_selections(update=False):
def copy_source(source, items, overwrite=False): def copy_source(source, items, overwrite=False):
"""Copy source items to /mnt/UFD.""" """Copy source items to /mnt/UFD."""
is_image = source.is_file() is_image = source.is_file()
items_not_found = False
# Mount source if necessary # Mount source if necessary
if is_image: if is_image:
@ -214,17 +209,20 @@ def copy_source(source, items, overwrite=False):
try: try:
io.recursive_copy(i_source, i_dest, overwrite=overwrite) io.recursive_copy(i_source, i_dest, overwrite=overwrite)
except FileNotFoundError: except FileNotFoundError:
# Going to assume (hope) that this is fine items_not_found = True
pass
# Unmount source if necessary # Unmount source if necessary
if is_image: if is_image:
linux.unmount('/mnt/Source') linux.unmount('/mnt/Source')
# Raise exception if item(s) were not found
raise FileNotFoundError('One or more items not found')
def create_table(dev_path, use_mbr=False): def create_table(dev_path, use_mbr=False):
"""Create GPT or DOS partition table.""" """Create GPT or DOS partition table."""
cmd = [ cmd = [
'sudo',
'parted', dev_path, 'parted', dev_path,
'--script', '--script',
'--', '--',
@ -261,6 +259,7 @@ def find_first_partition(dev_path):
def format_partition(dev_path, label): def format_partition(dev_path, label):
"""Format first partition on device FAT32.""" """Format first partition on device FAT32."""
cmd = [ cmd = [
'sudo',
'mkfs.vfat', 'mkfs.vfat',
'-F', '32', '-F', '32',
'-n', label, '-n', label,
@ -294,13 +293,14 @@ def hide_items(ufd_dev, items):
# Hide items # Hide items
for item in items: for item in items:
cmd = [f'yes | mattrib +h "U:/{item}"'] cmd = [f'yes | sudo mattrib +h "U:/{item}"']
run_program(cmd, check=False, shell=True) run_program(cmd, shell=True)
def install_syslinux_to_dev(ufd_dev, use_mbr): def install_syslinux_to_dev(ufd_dev, use_mbr):
"""Install Syslinux to UFD (dev).""" """Install Syslinux to UFD (dev)."""
cmd = [ cmd = [
'sudo',
'dd', 'dd',
'bs=440', 'bs=440',
'count=1', 'count=1',
@ -313,6 +313,7 @@ def install_syslinux_to_dev(ufd_dev, use_mbr):
def install_syslinux_to_partition(partition): def install_syslinux_to_partition(partition):
"""Install Syslinux to UFD (partition).""" """Install Syslinux to UFD (partition)."""
cmd = [ cmd = [
'sudo',
'syslinux', 'syslinux',
'--install', '--install',
'--directory', '--directory',
@ -342,6 +343,7 @@ def is_valid_path(path_obj, path_type):
def set_boot_flag(dev_path, use_mbr=False): def set_boot_flag(dev_path, use_mbr=False):
"""Set modern or legacy boot flag.""" """Set modern or legacy boot flag."""
cmd = [ cmd = [
'sudo',
'parted', dev_path, 'parted', dev_path,
'set', '1', 'set', '1',
'boot' if use_mbr else 'legacy_boot', 'boot' if use_mbr else 'legacy_boot',
@ -417,6 +419,7 @@ def update_boot_entries():
# Use UUID instead of label # Use UUID instead of label
cmd = [ cmd = [
'sudo',
'sed', 'sed',
'--in-place', '--in-place',
'--regexp-extended', '--regexp-extended',
@ -438,6 +441,7 @@ def update_boot_entries():
# Entry found, update config files # Entry found, update config files
cmd = [ cmd = [
'sudo',
'sed', 'sed',
'--in-place', '--in-place',
f's/#{b_comment}#//', f's/#{b_comment}#//',
@ -486,6 +490,7 @@ def verify_ufd(dev_path):
def zero_device(dev_path): def zero_device(dev_path):
"""Zero-out first 64MB of device.""" """Zero-out first 64MB of device."""
cmd = [ cmd = [
'sudo',
'dd', 'dd',
'bs=4M', 'bs=4M',
'count=16', 'count=16',

View file

@ -13,7 +13,10 @@ from wk.io import non_clobber_path
# STATIC VARIABLES # STATIC VARIABLES
if os.name == 'nt': if os.path.exists('/.wk-live-macos'):
# Workaround for live macOS env
DEFAULT_LOG_DIR = '/var/log/WizardKit'
elif os.name == 'nt':
# Example: "C:\WK\1955-11-05\WizardKit" # Example: "C:\WK\1955-11-05\WizardKit"
DEFAULT_LOG_DIR = ( DEFAULT_LOG_DIR = (
f'{os.environ.get("SYSTEMDRIVE", "C:")}/' f'{os.environ.get("SYSTEMDRIVE", "C:")}/'

View file

@ -181,7 +181,6 @@ def share_is_mounted(details):
if row['source'] == f'//{details["Address"]}/{details["Share"]}': if row['source'] == f'//{details["Address"]}/{details["Share"]}':
mounted = True mounted = True
break break
#TODO: Check mount status under Windows
#elif PLATFORM == 'Windows': #elif PLATFORM == 'Windows':
# Done # Done

View file

@ -179,7 +179,6 @@ def running_as_root():
def scan_corestorage_container(container, timeout=300): def scan_corestorage_container(container, timeout=300):
"""Scan CoreStorage container for inner volumes, returns list.""" """Scan CoreStorage container for inner volumes, returns list."""
# TODO: Test Scanning CoreStorage containers
detected_volumes = {} detected_volumes = {}
inner_volumes = [] inner_volumes = []
log_path = format_log_path(log_name=f'{container.path.name}_testdisk') log_path = format_log_path(log_name=f'{container.path.name}_testdisk')

View file

@ -15,7 +15,7 @@ from wk.std import GenericError, GenericWarning, sleep
# STATIC VARIABLES # STATIC VARIABLES
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
OS_VERSION = float(platform.win32_ver()[0]) # TODO: Check if Win8.1 returns '8' OS_VERSION = float(platform.win32_ver()[0])
REG_MSISERVER = r'HKLM\SYSTEM\CurrentControlSet\Control\SafeBoot\Network\MSIServer' REG_MSISERVER = r'HKLM\SYSTEM\CurrentControlSet\Control\SafeBoot\Network\MSIServer'
SLMGR = pathlib.Path(f'{os.environ.get("SYSTEMROOT")}/System32/slmgr.vbs') SLMGR = pathlib.Path(f'{os.environ.get("SYSTEMROOT")}/System32/slmgr.vbs')

View file

@ -134,6 +134,8 @@ class Menu():
checkmark = '*' checkmark = '*'
if 'DISPLAY' in os.environ or PLATFORM == 'Darwin': if 'DISPLAY' in os.environ or PLATFORM == 'Darwin':
checkmark = '' checkmark = ''
if os.path.exists('/.wk-live-macos'):
checkmark = '*'
display_name = f'{index if index else name[:1].upper()}: ' display_name = f'{index if index else name[:1].upper()}: '
if not (index and index >= 10): if not (index and index >= 10):
display_name = f' {display_name}' display_name = f' {display_name}'
@ -386,6 +388,7 @@ class Menu():
class TryAndPrint(): class TryAndPrint():
# pylint: disable=too-many-instance-attributes
"""Object used to standardize running functions and returning the result. """Object used to standardize running functions and returning the result.
The errors and warning attributes are used to allow fine-tuned results The errors and warning attributes are used to allow fine-tuned results
@ -394,11 +397,12 @@ class TryAndPrint():
def __init__(self, msg_bad='FAILED', msg_good='SUCCESS'): def __init__(self, msg_bad='FAILED', msg_good='SUCCESS'):
self.catch_all = True self.catch_all = True
self.indent = INDENT self.indent = INDENT
self.msg_bad = msg_bad
self.msg_good = msg_good
self.width = WIDTH
self.list_errors = ['GenericError'] self.list_errors = ['GenericError']
self.list_warnings = ['GenericWarning'] self.list_warnings = ['GenericWarning']
self.msg_bad = msg_bad
self.msg_good = msg_good
self.verbose = False
self.width = WIDTH
def _format_exception_message(self, _exception): def _format_exception_message(self, _exception):
"""Format using the exception's args or name, returns str.""" """Format using the exception's args or name, returns str."""
@ -523,13 +527,13 @@ class TryAndPrint():
def run( def run(
self, message, function, *args, self, message, function, *args,
catch_all=None, msg_good=None, verbose=False, **kwargs): catch_all=None, msg_good=None, verbose=None, **kwargs):
# pylint: disable=catching-non-exception # pylint: disable=catching-non-exception
"""Run a function and print the results, returns results as dict. """Run a function and print the results, returns results as dict.
If catch_all is True then (nearly) all exceptions will be caught. If catch_all is True then (nearly) all exceptions will be caught.
Otherwise if an exception occurs that wasn't specified it will be Otherwise if an exception occurs that wasn't specified it will be
re-raised. If passed it will override self.catch_all for this call. re-raised.
If the function returns data it will be used instead of msg_good, If the function returns data it will be used instead of msg_good,
msg_bad, or exception text. msg_bad, or exception text.
@ -540,6 +544,9 @@ class TryAndPrint():
If verbose is True then exception names or messages will be used for If verbose is True then exception names or messages will be used for
the result message. Otherwise it will simply be set to result_bad. the result message. Otherwise it will simply be set to result_bad.
If catch_all and/or verbose are passed it will override
self.catch_all and/or self.verbose for this call.
args and kwargs are passed to the function. args and kwargs are passed to the function.
""" """
LOG.debug('function: %s.%s', function.__module__, function.__name__) LOG.debug('function: %s.%s', function.__module__, function.__name__)
@ -556,6 +563,8 @@ class TryAndPrint():
result_msg = 'UNKNOWN' result_msg = 'UNKNOWN'
if catch_all is None: if catch_all is None:
catch_all = self.catch_all catch_all = self.catch_all
if verbose is None:
verbose = self.verbose
# Build exception tuples # Build exception tuples
e_exceptions = tuple(self._get_exception(e) for e in self.list_errors) e_exceptions = tuple(self._get_exception(e) for e in self.list_errors)
@ -726,7 +735,11 @@ def choice(choices, prompt='答えろ!'):
def clear_screen(): def clear_screen():
"""Simple wrapper for clear/cls.""" """Simple wrapper for clear/cls."""
cmd = 'cls' if os.name == 'nt' else 'clear' cmd = 'cls' if os.name == 'nt' else 'clear'
subprocess.run(cmd, check=False, shell=True, stderr=subprocess.PIPE) proc = subprocess.run(cmd, check=False, shell=True, stderr=subprocess.PIPE)
# Workaround for live macOS env
if proc.returncode != 0:
print('\033c')
def color_string(strings, colors, sep=' '): def color_string(strings, colors, sep=' '):

View file

@ -234,7 +234,7 @@
</menu> </menu>
<separator/> <separator/>
<item label="Exit"> <action name="Execute"> <item label="Exit"> <action name="Execute">
<execute>oblogout</execute> <execute>wk-exit</execute>
</action> </item> </action> </item>
</menu> </menu>
</openbox_menu> </openbox_menu>