Files
cthulhu/src/cthulhu.py

381 lines
13 KiB
Python

#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
import argparse
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
import os
import shutil
import signal
import subprocess
import sys
import time
def setup_paths():
"""Configure paths for both installed and source directory execution."""
currentDir = os.path.dirname(os.path.abspath(__file__))
# Check if running from source
if os.path.exists(os.path.join(currentDir, 'plugins')):
# Running from source directory
sys.path.insert(0, os.path.dirname(currentDir))
pythondir = currentDir
datadir = currentDir
else:
# Running installed - determine if local or system based on actual path
if currentDir.startswith(os.path.expanduser('~/.local')):
# Local installation (~/.local/bin/cthulhu)
prefix = os.path.expanduser('~/.local')
elif currentDir.startswith('/usr/local'):
# /usr/local installation
prefix = '/usr/local'
else:
# System installation (/usr/bin/cthulhu)
prefix = '/usr'
# Try to find Python modules in multiple possible locations
python_version = f'python{sys.version_info.major}.{sys.version_info.minor}'
possible_pythondirs = [
os.path.join(prefix, 'lib', python_version, 'site-packages'),
os.path.join('/usr', 'lib', python_version, 'site-packages'), # System fallback
os.path.join('/usr/local', 'lib', python_version, 'site-packages') # /usr/local fallback
]
# Use the first directory that contains the cthulhu module
pythondir = None
for candidate_dir in possible_pythondirs:
if os.path.exists(os.path.join(candidate_dir, 'cthulhu', '__init__.py')):
pythondir = candidate_dir
break
if pythondir is None:
# Fallback to prefix-relative path if module not found
pythondir = os.path.join(prefix, 'lib', python_version, 'site-packages')
datadir = os.path.join(prefix, 'share', 'cthulhu')
sys.path.insert(1, pythondir)
# Set environment variables for resource paths
if 'CTHULHU_DATA_DIR' not in os.environ:
os.environ['CTHULHU_DATA_DIR'] = datadir
# Set up paths before importing Cthulhu modules
setup_paths()
from cthulhu import debug
from cthulhu import messages
from cthulhu import settings
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities
from cthulhu.cthulhu_platform import version
class ListApps(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
desktop = AXUtilities.get_desktop()
for app in AXObject.iter_children(desktop):
pid = AXObject.get_process_id(app)
try:
name = Atspi.Accessible.get_name(app) or "(none)"
except Exception:
name = "[DEAD]"
try:
cmdline = subprocess.getoutput('cat /proc/%s/cmdline' % pid)
except Exception:
cmdline = '(exception encountered)'
else:
cmdline = cmdline.replace('\x00', ' ')
print(time.strftime('%H:%M:%S', time.localtime()),
' pid: %5s %-25s %s' % (pid, name, cmdline))
parser.exit()
class Settings(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
settingsDict = getattr(namespace, 'settings', {})
invalid = getattr(namespace, 'invalid', [])
for value in values.split(','):
item = str.title(value).replace('-', '')
try:
test = 'enable%s' % item
eval('settings.%s' % test)
except AttributeError:
try:
test = 'show%s' % item
eval('settings.%s' % test)
except AttributeError:
invalid.append(value)
continue
settingsDict[test] = self.const
setattr(namespace, 'settings', settingsDict)
setattr(namespace, 'invalid', invalid)
class HelpFormatter(argparse.HelpFormatter):
def __init__(self, prog, indent_increment=2, max_help_position=32,
width=None):
super().__init__(prog, indent_increment, max_help_position, width)
def add_usage(self, usage, actions, groups, prefix=None):
super().add_usage(usage, actions, groups, messages.CLI_USAGE)
class Parser(argparse.ArgumentParser):
def __init__(self, *args, **kwargs):
super(Parser, self).__init__(
epilog=messages.CLI_EPILOG, formatter_class=HelpFormatter, add_help=False)
self.add_argument(
"-h", "--help", action="help", help=messages.CLI_HELP)
self.add_argument(
"-v", "--version", action="version", version=version, help=messages.CLI_VERSION)
self.add_argument(
"-r", "--replace", action="store_true", help=messages.CLI_REPLACE)
self.add_argument(
"-s", "--setup", action="store_true", help=messages.CLI_GUI_SETUP)
self.add_argument(
"-l", "--list-apps", action=ListApps, nargs=0,
help=messages.CLI_LIST_APPS)
self.add_argument(
"-e", "--enable", action=Settings, const=True,
help=messages.CLI_ENABLE_OPTION, metavar=messages.CLI_OPTION)
self.add_argument(
"-d", "--disable", action=Settings, const=False,
help=messages.CLI_DISABLE_OPTION, metavar=messages.CLI_OPTION)
self.add_argument(
"-p", "--profile", action="store",
help=messages.CLI_LOAD_PROFILE, metavar=messages.CLI_PROFILE_NAME)
self.add_argument(
"-u", "--user-prefs", action="store",
help=messages.CLI_LOAD_PREFS, metavar=messages.CLI_PREFS_DIR)
self.add_argument(
"--debug-file", action="store",
help=messages.CLI_DEBUG_FILE, metavar=messages.CLI_DEBUG_FILE_NAME)
self.add_argument(
"--debug", action="store_true", help=messages.CLI_ENABLE_DEBUG)
self._optionals.title = messages.CLI_OPTIONAL_ARGUMENTS
def parse_known_args(self, *args, **kwargs):
opts, invalid = super(Parser, self).parse_known_args(*args, **kwargs)
try:
invalid.extend(opts.invalid)
except Exception:
pass
if invalid:
print((messages.CLI_INVALID_OPTIONS + " ".join(invalid)))
if opts.debug_file:
opts.debug = True
elif opts.debug:
opts.debug_file = time.strftime('debug-%Y-%m-%d-%H:%M:%S.out')
return opts, invalid
def setProcessName(name):
"""Attempts to set the process name to the specified name."""
sys.argv[0] = name
try:
from setproctitle import setproctitle
except ImportError:
pass
else:
setproctitle(name)
return True
try:
from ctypes import cdll, byref, create_string_buffer
libc = cdll.LoadLibrary('libc.so.6')
stringBuffer = create_string_buffer(len(name) + 1)
stringBuffer.value = bytes(name, 'UTF-8')
libc.prctl(15, byref(stringBuffer), 0, 0, 0)
return True
except Exception:
pass
return False
def inGraphicalDesktop():
"""Returns True if we are in a graphical desktop."""
# TODO - JD: Make this desktop environment agnostic
try:
import gi
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
display = Gdk.Display.get_default()
except Exception:
return False
return display is not None
def getSessionType():
sessionType = (os.environ.get("XDG_SESSION_TYPE") or "").strip().lower()
if sessionType:
return sessionType
if os.environ.get("WAYLAND_DISPLAY"):
return "wayland"
if os.environ.get("DISPLAY"):
return "x11"
return "unknown"
def getXServerVendor():
display = os.environ.get("DISPLAY")
if not display:
return None
xdpyinfoPath = shutil.which("xdpyinfo")
if not xdpyinfoPath:
return None
try:
result = subprocess.run(
[xdpyinfoPath, "-display", display],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=1,
)
except Exception:
return None
for line in result.stdout.splitlines():
if "vendor string:" in line:
return line.split("vendor string:", 1)[1].strip()
return None
def otherCthulhus():
"""Returns the pid of any other instances of Cthulhu owned by this user."""
openFile = subprocess.Popen('pgrep -u %s -x cthulhu' % os.getuid(),
shell=True,
stdout=subprocess.PIPE).stdout
pids = openFile.read()
openFile.close()
cthulhus = [int(p) for p in pids.split()]
pid = os.getpid()
return [p for p in cthulhus if p != pid]
def cleanup(sigval):
"""Tries to clean up any other running Cthulhu instances owned by this user."""
cthulhusToKill = otherCthulhus()
debug.printMessage(debug.LEVEL_INFO, "INFO: Cleaning up these PIDs: %s" % cthulhusToKill)
def onTimeout(signum, frame):
cthulhusToKill = otherCthulhus()
debug.printMessage(debug.LEVEL_INFO, "INFO: Timeout cleaning up: %s" % cthulhusToKill)
for pid in cthulhusToKill:
os.kill(pid, signal.SIGKILL)
for pid in cthulhusToKill:
os.kill(pid, sigval)
signal.signal(signal.SIGALRM, onTimeout)
signal.alarm(2)
while otherCthulhus():
time.sleep(0.5)
def main():
setProcessName('cthulhu')
parser = Parser()
args, invalid = parser.parse_known_args()
if args.debug:
debug.debugLevel = debug.LEVEL_ALL
debug.eventDebugLevel = debug.LEVEL_OFF
debug.debugFile = open(args.debug_file, 'w')
if args.replace:
cleanup(signal.SIGKILL)
settingsDict = getattr(args, 'settings', {})
if not inGraphicalDesktop():
print(messages.CLI_NO_DESKTOP_ERROR)
return 1
sessionType = getSessionType()
sessionDetails = []
xdgSessionType = os.environ.get("XDG_SESSION_TYPE")
if xdgSessionType:
sessionDetails.append(f"XDG_SESSION_TYPE={xdgSessionType}")
if sessionType == "wayland":
waylandDisplay = os.environ.get("WAYLAND_DISPLAY")
if waylandDisplay:
sessionDetails.append(f"WAYLAND_DISPLAY={waylandDisplay}")
elif sessionType == "x11":
display = os.environ.get("DISPLAY")
if display:
sessionDetails.append(f"DISPLAY={display}")
vendor = getXServerVendor()
if vendor:
sessionDetails.append(f"X server vendor={vendor}")
if sessionDetails:
msg = f"INFO: Session: {sessionType} ({', '.join(sessionDetails)})"
else:
msg = f"INFO: Session: {sessionType}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
debug.printMessage(debug.LEVEL_INFO, "INFO: Preparing to launch.", True)
from cthulhu import cthulhu
manager = cthulhu.getSettingsManager()
if not manager:
print(messages.CLI_SETTINGS_MANAGER_ERROR)
return 1
debug.printMessage(debug.LEVEL_INFO, "INFO: About to activate settings manager.", True)
manager.activate(args.user_prefs, settingsDict)
sys.path.insert(0, manager.getPrefsDir())
if args.profile:
try:
manager.setProfile(args.profile)
except Exception:
print(messages.CLI_LOAD_PROFILE_ERROR % args.profile)
manager.setProfile()
if args.setup:
cleanup(signal.SIGKILL)
cthulhu.showPreferencesGUI()
if otherCthulhus():
print(messages.CLI_OTHER_CTHULHUS_ERROR)
return 1
debug.printMessage(debug.LEVEL_INFO, "INFO: About to launch Cthulhu.", True)
return cthulhu.main()
if __name__ == "__main__":
sys.exit(main())