#!/usr/bin/python3.12
# -*- coding: utf-8 -*-
# vi: ft=python:tw=0:sw=4:ts=4:noet
# Author:		Jan Christoph Ebersbach <jceb@e-jc.de>
# Last Modified: Thu 12. Jul 2012 20:16:13 +0200 CEST

# dex
# DesktopEntry Execution, is a program to generate and execute DesktopEntry
# files of the type Application
#
# Depends: None
#
# Copyright (C) 2010, 2011, 2012, 2013 Jan Christoph Ebersbach
#
# http://www.e-jc.de/
#
# All rights reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program.  If not, see <http://www.gnu.org/licenses/>.


import glob
import os
import subprocess
import sys

__version__ = "0.8.0"


# DesktopEntry exceptions
class DesktopEntryTypeException(Exception):
	def __init__(self, value):
		self.value = value

	def __str__(self):
		return repr(self.value)


class ApplicationExecException(Exception):
	def __init__(self, value):
		self.value = value
		Exception.__init__(self, value)

	def __str__(self):
		return repr(self.value)


# DesktopEntry class definitions
class DesktopEntry(object):
	"""
	Implements some parts of Desktop Entry specification:
	http://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-1.1.html
	"""
	def __init__(self, filename=None):
		"""
		@param	filename	Desktop Entry File
		"""
		if filename is not None and os.path.islink(filename) and \
				os.readlink(filename) == os.path.sep + os.path.join('dev', 'null'):
			# ignore links to /dev/null
			pass
		elif filename is None or not os.path.isfile(filename):
			raise IOError('File does not exist: %s' % filename)
		self._filename = filename
		self.groups = {}

	def __str__(self):
		if self.Name:
			return self.Name
		elif self.filename:
			return self.filename
		return repr(self)

	def __lt__(self, y):
		return self.filename < y.filename

	@property
	def filename(self):
		"""
		The absolute filename
		"""
		return self._filename

	@classmethod
	def fromfile(cls, filename):
		"""Create DesktopEntry for file

		@params	filename	Create a DesktopEntry object for file and determine
							the type automatically
		"""

		de = cls(filename=filename)

		# determine filetype
		de_type = 'Link'
		if os.path.exists(filename):
			if os.path.isdir(filename):
				de_type = 'Directory'
				# TODO fix the value for directories
				de.set_value('??', filename)
			else:
				de_type = 'Application'
				de.set_value('Exec', filename)
				de.set_value('Name', os.path.basename(filename))
				if os.name == 'posix':
					whatis = subprocess.Popen(['whatis', filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
					stdout, stderr = whatis.communicate()
					res = stdout.decode(sys.stdin.encoding).split('- ', 1)
					if len(res) == 2:
						de.set_value('Comment', res[1].split(os.linesep, 1)[0])
		else:
			# type Link
			de.set_value('URL', filename)

		de.set_value('Type', de_type)

		return de

	def load(self):
		"""Load or reload contents of desktop entry file"""
		self.groups = {}     # clear settings
		grp_desktopentry = 'Desktop Entry'

		_f = open(self.filename, 'r')
		current_group = None
		try:
			for l in _f.readlines():
				l = l.strip('\n')
				# handle comments and empty lines
				if l.startswith('#') or l.strip() == '':
					continue

				# handle groups
				if l.startswith('['):
					if not l.endswith(']'):
						raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because of line '%s'." % (self.filename, l))
					group = l[1:-1]
					if self.groups.get(group, None):
						raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because group '%s' is specified multiple times." % (self.filename, group))
					current_group = group
					continue

				# handle all the other lines
				if not current_group:
					raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because line '%s' does not belong to a group." % (self.filename, l))
				kv = l.split('=', 1)
				if len(kv) != 2 or kv[0] == '':
					raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because line '%s' is not a valid key=value pair." % (self.filename, l))
				k = kv[0]
				v = kv[1]
				# TODO: parse k for locale specific settings
				# TODO: parse v for multivalue fields
				self.set_value(k, v, current_group)
		except Exception as ex:
			_f.close()
			raise ex
		finally:
			_f.close()

		if grp_desktopentry not in self.groups:
			raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry group is missing." % (self.filename, ))
		if not (self.Type and self.Name):
			if self.Type != 'Service':
				# allow files with type Service and no Name
				raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because Type or Name keys are missing." % (self.filename, ))
		_type = self.Type
		if  _type in ('Application', 'Service'):
			if not self.Exec:
				raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry of type '%s' because Exec is missing." % (self.filename, _type))
		elif  _type == 'Link':
			if not self.URL:
				raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry of type '%s' because URL is missing." % (self.filename, _type))
		elif  _type == 'Directory':
			pass
		else:
			raise DesktopEntryTypeException("'%s' is not a valid Desktop Entry because Type '%s' is unknown." % (self.filename, self.Type))

	# another name for load
	reload = load

	def write(self, fileobject):
		"""Write DesktopEntry to a file

		@param	fileobject	DesktopEntry is written to file
		"""
		for group in self.groups:
			fileobject.write('[%s]\n' % (group, ))
			for key in self.groups[group]:
				fileobject.write('%s=%s\n' % (key, self.groups[group][key]))

	def set_value(self, key, value, group='Desktop Entry'):
		""" Set a key, value pair in group

		@param	key	Key
		@param	value	Value
		@param	group	The group key and value are set in. Default: Desktop Entry
		"""
		if group not in self.groups:
			self.groups[group] = {}
		self.groups[group][key] = str(value)
		return value

	def _get_value(self, key, group='Desktop Entry', default=None):
		if not self.groups:
			self.load()
		if group not in self.groups:
			raise KeyError("Group '%s' not found." % group)
		grp = self.groups[group]
		if key not in grp:
			return default
		return grp[key]

	def get_boolean(self, key, group='Desktop Entry', default=False):
		val = self._get_value(key, group=group, default=default)
		if type(val) == bool:
			return val
		if val in ['true', 'True']:
			return True
		if val in ['false', 'False']:
			return False
		raise ValueError("'%s's value '%s' in group '%s' is not a boolean value." % (key, val, group))

	def get_list(self, key, group='Desktop Entry', default=None):
		list_of_strings = []
		res = self.get_string(key, group=group, default=default)
		if type(res) == str:
			list_of_strings = [x for x in res.split(';') if x]
		return list_of_strings

	def get_string(self, key, group='Desktop Entry', default=''):
		return self._get_value(key, group=group, default=default)

	def get_strings(self, key, group='Desktop Entry', default=''):
		raise Exception("Not implemented yet.")

	def get_localestring(self, key, group='Desktop Entry', default=''):
		raise Exception("Not implemented yet.")

	def get_numeric(self, key, group='Desktop Entry', default=0.0):
		val = self._get_value(key, group=group, default=default)
		if type(val) == float:
			return val
		return float(val)

	@property
	def Type(self):
		return self.get_string('Type')

	@property
	def Version(self):
		return self.get_string('Version')

	@property
	def Name(self):
		# SHOULD be localestring!
		return self.get_string('Name')

	@property
	def GenericName(self):
		return self.get_localestring('GenericName')

	@property
	def NoDisplay(self):
		return self.get_boolean('NoDisplay')

	@property
	def Comment(self):
		return self.get_localestring('Comment')

	@property
	def Icon(self):
		return self.get_localestring('Icon')

	@property
	def Hidden(self):
		return self.get_boolean('Hidden')

	@property
	def OnlyShowIn(self):
		return self.get_list('OnlyShowIn')

	@property
	def NotShowIn(self):
		return self.get_list('NotShowIn')

	@property
	def TryExec(self):
		return self.get_string('TryExec')

	@property
	def Exec(self):
		return self.get_string('Exec')

	@property
	def Path(self):
		return self.get_string('Path')

	@property
	def Terminal(self):
		return self.get_boolean('Terminal')

	@property
	def MimeType(self):
		return self.get_strings('MimeType')

	@property
	def Categories(self):
		return self.get_strings('Categories')

	@property
	def StartupNotify(self):
		return self.get_boolean('StartupNotify')

	@property
	def StartupWMClass(self):
		return self.get_string('StartupWMClass')

	@property
	def URL(self):
		return self.get_string('URL')


class Application(DesktopEntry):
	"""
	Implements application files
	"""

	def __init__(self, filename):
		"""
		@param	filename	Absolute path to a Desktop Entry File
		"""
		if not os.path.isabs(filename):
			filename = os.path.join(os.getcwd(), filename)
		super(Application, self).__init__(filename)
		self._basename = os.path.basename(filename)
		if self.Type not in ('Application', 'Service'):
			raise DesktopEntryTypeException("'%s' is not of type 'Application'." % self.filename)

	def __cmp__(self, y):
		"""
		@param	y	The object to compare the current object with - comparison is made on the property of basename
		"""
		if isinstance(y, Application):
			return cmp(y.basename, self.basename)
		return -1

	def __eq__(self, y):
		"""
		@param	y	The object to compare the current object with - comparison is made on the property of basename
		"""
		if isinstance(y, Application):
			return y.basename == self.basename
		return False

	@property
	def basename(self):
		"""
		The basename of file
		"""
		return self._basename

	@classmethod
	def _build_cmd(cls, exec_string, needs_terminal=False, term='x-terminal-emulator'):
		"""
		# test single and multi argument commands
		>>> Application._build_cmd('gvim')
		['gvim']
		>>> Application._build_cmd('gvim test')
		['gvim', 'test']

		# test quotes
		>>> Application._build_cmd('"gvim" test')
		['gvim', 'test']
		>>> Application._build_cmd('"gvim test"')
		['gvim test']

		# test escape sequences
		>>> Application._build_cmd('"gvim test" test2 "test \\\\" 3"')
		['gvim test', 'test2', 'test " 3']
		>>> Application._build_cmd(r'"test \\\\\\\\ \\" moin" test')
		['test \\\\ " moin', 'test']
		>>> Application._build_cmd(r'"gvim \\\\\\\\ \\`test\\$"')
		['gvim \\\\ `test$']
		>>> Application._build_cmd(r'vim ~/.vimrc', True)
		['x-terminal-emulator', '-e', 'vim', '~/.vimrc']
		>>> Application._build_cmd('vim ~/.vimrc', False)
		['vim', '~/.vimrc']
		>>> Application._build_cmd("vim '~/.vimrc test'", False)
		['vim', '~/.vimrc test']
		>>> Application._build_cmd('vim \\'~/.vimrc " test\\'', False)
		['vim', '~/.vimrc " test']
		>>> Application._build_cmd('sh -c \\'vim ~/.vimrc " test\\'', False)
		['sh', '-c', 'vim ~/.vimrc " test']
		>>> Application._build_cmd("sh -c 'vim ~/.vimrc \\" test\\"'", False)
		['sh', '-c', 'vim ~/.vimrc " test"']

		# expand field codes by removing them
		>>> Application._build_cmd("vim %u", False)
		['vim']
		>>> Application._build_cmd("vim ~/.vimrc %u", False)
		['vim', '~/.vimrc']
		>>> Application._build_cmd("vim '%u' ~/.vimrc", False)
		['vim', '%u', '~/.vimrc']
		>>> Application._build_cmd("vim %u ~/.vimrc", False)
		['vim', '~/.vimrc']
		>>> Application._build_cmd("vim /%u/.vimrc", False)
		['vim', '//.vimrc']
		>>> Application._build_cmd("vim %u/.vimrc", False)
		['vim', '/.vimrc']
		>>> Application._build_cmd("vim %U/.vimrc", False)
		['vim', '/.vimrc']
		>>> Application._build_cmd("vim /%U/.vimrc", False)
		['vim', '//.vimrc']
		>>> Application._build_cmd("vim %U .vimrc", False)
		['vim', '.vimrc']

		# preserved escaped field codes
		>>> Application._build_cmd("vim \\\\%u ~/.vimrc", False)
		['vim', '%u', '~/.vimrc']

		# test for non-valid field codes, they should be preserved
		>>> Application._build_cmd("vim %x .vimrc", False)
		['vim', '%x', '.vimrc']
		>>> Application._build_cmd("vim %x/.vimrc", False)
		['vim', '%x/.vimrc']
		"""
		cmd = []
		if needs_terminal:
			cmd += [term, '-e']
		_tmp = exec_string.replace('\\\\', '\\')
		_arg = ''
		in_esc = False
		in_quote = False
		in_singlequote = False
		in_fieldcode = False

		for c in _tmp:
			if in_esc:
				in_esc = False
			else:
				if in_fieldcode:
					in_fieldcode = False
					if c in ('u', 'U', 'f', 'F'):
						# TODO ignore field codes for the moment; at some point
						# field codes should be supported
						# strip %-char at the end of the argument
						_arg = _arg[:-1]
						continue

				if c == '"':
					if in_quote:
						in_quote = False
						cmd.append(_arg)
						_arg = ''
						continue
					if not in_singlequote:
						in_quote = True
						continue

				elif c == "'":
					if in_singlequote:
						in_singlequote = False
						cmd.append(_arg)
						_arg = ''
						continue
					if not in_quote:
						in_singlequote = True
						continue

				elif c == '\\':
					if not in_quote:
						in_esc = True
						continue

				elif c == '%' and not (in_quote or in_singlequote):
					in_fieldcode = True

				elif c == ' ' and not (in_quote or in_singlequote):
					if not _arg:
						continue
					cmd.append(_arg)
					_arg = ''
					continue

			_arg += c

		if _arg and not (in_esc or in_quote or in_singlequote):
			cmd.append(_arg)
		elif _arg:
			raise ApplicationExecException('Exec value contains an unbalanced number of quote characters.')

		return cmd

	def execute(self, term=None, wait=False, dryrun=False, verbose=False):
		"""
		Execute application
		@return	Return subprocess.Popen object
		"""
		_exec = True
		_try = self.TryExec
		if _try and not (os.path.isabs(_try) and os.path.isfile(_try)) and not which(_try):
			_exec = False

		if _exec:
			path = self.Path
			cmd = self._build_cmd(exec_string=self.Exec, needs_terminal=self.Terminal, term=term)
			if not cmd:
				raise ApplicationExecException('Failed to build command string.')
			if dryrun or verbose:
				if verbose:
					print('Autostart file: %s' % self.filename)
				if path:
					print('Changing directory to: ' + path)
				print('Executing command: ' + ' '.join(cmd))
			if dryrun:
				return None
			_execute_fn = subprocess.Popen
			if wait:
				_execute_fn = subprocess.run
			if path:
				return _execute_fn(cmd, cwd=path, env=os.environ)
			return _execute_fn(cmd, env=os.environ)


class AutostartFile(Application):
	"""
	Implements autostart files
	"""

	def __init__(self, filename):
		"""
		@param	filename	Absolute path to a Desktop Entry File
		"""
		super(AutostartFile, self).__init__(filename)


class EmptyAutostartFile(Application):
	"""
	Workaround for empty autostart files that don't contain the necessary data
	"""

	def __init__(self, filename):
		"""
		@param	filename	Absolute path to a Desktop Entry File
		"""
		try:
			super(EmptyAutostartFile, self).__init__(filename)
		except DesktopEntryTypeException:
			# ignore the missing type information
			pass


# local methods
def which(filename):
	path = os.environ.get('PATH', None)
	if path:
		for _p in path.split(os.pathsep):
			_f = os.path.join(_p, filename)
			if os.path.isfile(_f):
				return _f


def get_autostart_directories():
	"""
	Generate the list of autostart directories
	"""
	autostart_directories = []  # autostart directories, ordered by preference

	if args.searchpaths:
		for p in args.searchpaths[0].split(os.pathsep):
			path = os.path.expanduser(p)
			path = os.path.expandvars(path)
			autostart_directories += [path]
	else:
		# generate list of autostart directories
		if os.environ.get('XDG_CONFIG_HOME', None):
			autostart_directories.append(os.path.join(os.environ.get('XDG_CONFIG_HOME'), 'autostart'))
		else:
			autostart_directories.append(os.path.join(os.environ['HOME'], '.config', 'autostart'))

		if os.environ.get('XDG_CONFIG_DIRS', None):
			for d in os.environ['XDG_CONFIG_DIRS'].split(os.pathsep):
				if not d:
					continue
				autostart_dir = os.path.join(d, 'autostart')
				if autostart_dir not in autostart_directories:
					autostart_directories.append(autostart_dir)
		else:
			autostart_directories.append(os.path.sep + os.path.join('etc', 'xdg', 'autostart'))
	return autostart_directories


def get_autostart_files(args, verbose=False):
	"""
	Generate a list of autostart files according to autostart-spec 0.5

	TODO: do filetype recognition according to spec
	"""
	environment = args.environment[0].lower() if args.environment else ''

	autostart_files = []  # autostart files, excluding files marked as hidden
	non_autostart_files = []

	for d in get_autostart_directories():
		for _f in glob.glob1(d, '*.desktop'):
			_f = os.path.join(d, _f)
			af = None

			if os.path.isfile(_f) or os.path.islink(_f):
				try:
					af = AutostartFile(_f)
				except DesktopEntryTypeException as ex:
					af = EmptyAutostartFile(_f)
					if af not in autostart_files and not af in non_autostart_files:
						non_autostart_files.append(af)
					continue
				except ValueError as ex:
					if verbose:
						print(ex, file=sys.stderr)
					continue
				except IOError as ex:
					if verbose:
						print(ex, file=sys.stderr)
					continue
			else:
				if verbose:
					print('Ignoring unknown file: %s' % _f, file=sys.stderr)
				continue

			if verbose:
				if af.NotShowIn:
					print('Not show in environments %s: %s' % (', '.join(af.NotShowIn), af.filename), file=sys.stderr)
				if af.OnlyShowIn:
					print('Only show in environments %s: %s' % (', '.join(af.OnlyShowIn), af.filename), file=sys.stderr)
			if af in autostart_files or af in non_autostart_files:
				if verbose:
					print('Ignoring file, overridden by other autostart file: %s' % af.filename, file=sys.stderr)
				continue
			elif af.Hidden:
				if verbose:
					print('Ignoring file, hidden attribute is set: %s' % af.filename, file=sys.stderr)
				non_autostart_files.append(af)
				continue
			elif environment:
				if environment in [x.lower() for x in af.NotShowIn]:
					if verbose:
						print('Ignoring file, it must not start in specific environments (%s): %s' % (', '.join(af.NotShowIn), af.filename), file=sys.stderr)
					non_autostart_files.append(af)
					continue
				elif af.OnlyShowIn and environment not in [x.lower() for x in af.OnlyShowIn]:
					if verbose:
						print('Ignoring file, it must only start in specific environments (%s): %s' % (', '.join(af.OnlyShowIn), af.filename), file=sys.stderr)
					non_autostart_files.append(af)
					continue
			autostart_files.append(af)
	if verbose:
		for i in non_autostart_files:
			print('Ignoring empty file: %s' % i.filename, file=sys.stderr)
	return sorted(autostart_files)


def _test(args):
	"""
	run tests
	"""
	import doctest
	doctest.testmod()


def _autostart(args):
	"""
	perform autostart
	"""
	if args.dryrun and args.verbose:
		print('Dry run, nothing is executed.', file=sys.stderr)

	exit_value = 0
	for app in get_autostart_files(args, verbose=args.verbose):
		try:
			app.execute(dryrun=args.dryrun, verbose=args.verbose)
		except Exception as ex:
			exit_value = 1
			print("Execution faild: %s%s%s" % (app.filename, os.linesep, ex), file=sys.stderr)


def _run(args):
	"""
	execute specified DesktopEntry files
	"""
	if args.dryrun and args.verbose:
		print('Dry run, nothing is executed.', file=sys.stderr)

	exit_value = 0
	if not args.files:
		print("Nothing to execute, no DesktopEntry files specified!", file=sys.stderr)
		parser.print_help()
		exit_value = 1
	else:
		for f in args.files:
			try:
				app = Application(f)
				app.execute(term=args.term, wait=args.wait, dryrun=args.dryrun, verbose=args.verbose)
			except ValueError as ex:
				print(ex, file=sys.stderr)
			except IOError as ex:
				print(ex, file=sys.stderr)
			except Exception as ex:
				exit_value = 1
				print("Execution faild: %s%s%s" % (f, os.linesep, ex), file=sys.stderr)
	return exit_value


def _create(args):
	"""
	create a new DesktopEntry file from the given argument
	"""
	target = args.create[0]
	if args.verbose:
		print('Creating DesktopEntry for file %s.' % target)

	de = DesktopEntry.fromfile(target)
	if args.verbose:
		print('Type: %s' % de.Type)

	# determine output file
	output = '.'.join((os.path.basename(target), 'directory' if
		de.Type == 'Directory' else 'desktop'))
	if args.targetdir:
		output = os.path.join(args.targetdir[0], output)
	elif len(args.create) > 1:
		output = args.create[1]

	if args.verbose:
		print('Output: %s' % output)

	try:
		targetfile = sys.stdout if output == '-' else open(output, 'w')
	except FileNotFoundError:
		print('Target directory does not exist: %s' % os.path.dirname(output))
		return 1
	de.write(targetfile)

	if args.targetdir and len(args.create) > 1:
		args.create = args.create[1:]
		return _create(args)
	return 0


# start execution
if __name__ == '__main__':
	from argparse import ArgumentParser
	parser = ArgumentParser(usage='%(prog)s [options] [DesktopEntryFile [DesktopEntryFile ...]]',
			description='dex, DesktopEntry Execution, is a program to generate and execute DesktopEntry files of the type Application', epilog='Example usage: list autostart programs: dex -ad')
	parser.add_argument("--test", action="store_true", dest="test", help="perform a self-test")
	parser.add_argument("-v", "--verbose", action="store_true", dest="verbose", help="verbose output")
	parser.add_argument("-V", "--version", action="store_true", dest="version", help="display version information")
	parser.add_argument('files', nargs='*', help="DesktopEntry files")

	run = parser.add_argument_group('run')
	run.add_argument("-a", "--autostart", action="store_true", dest="autostart", help="autostart programs")
	run.add_argument("-d", "--dry-run", action="store_true", dest="dryrun", help="dry run, don't execute any command")
	run.add_argument("-e", "--environment", nargs=1, dest="environment", help="specify the Desktop Environment an autostart should be performed for; works only in combination with --autostart")
	run.add_argument("-s", "--search-paths", nargs=1, dest="searchpaths", help="colon separated list of paths to search for desktop files, overriding the default search list")
	run.add_argument("--term", dest="term", help="the terminal emulator that will be used to run the program if Terminal=true is set in the desktop file, defaults to x-terminal-emulator")
	run.add_argument("-w", "--wait", action="store_true", dest="wait", help="block until the program exits")

	create = parser.add_argument_group('create')
	create.add_argument("-c", "--create", nargs='+', dest="create", help="create a DesktopEntry file for the given program. If a second argument is provided it's taken as output filename or written to stdout (filename: -). By default a new file with the postfix .desktop is created")
	create.add_argument("-t", "--target-directory", nargs=1, dest="targetdir", help="create files in target directory")

	parser.set_defaults(func=_run, term="x-terminal-emulator", wait=False, dryrun=False, test=False, autostart=False, verbose=False)

	args = parser.parse_args()
	if args.autostart:
		args.func = _autostart
	elif args.create:
		args.func = _create
	elif args.test:
		args.func = _test

	# display version information
	if args.version:
		print("dex %s" % __version__)
	else:
		sys.exit(args.func(args))
