From f539d39e1f0a5d4ba1b141341f8eb6057e1c1fd9 Mon Sep 17 00:00:00 2001 From: Mike Hendricks Date: Fri, 5 Aug 2022 00:02:24 -0700 Subject: [PATCH] Add app id management and create shortcut --- README.md | 12 ++ casement/app_id.py | 87 +++++++++++++++ casement/shortcut.py | 254 +++++++++++++++++++++++++++++++++++++++++-- docs/source/conf.py | 3 +- setup.cfg | 3 + 5 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 casement/app_id.py diff --git a/README.md b/README.md index c6259c1..50b2bce 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ A Python library that provides useful functionality for managing Microsoft Windows systems. +## Features + +* App Model Id management for shortcuts and inside python applications to allow for taskbar grouping. +* Finding, creating and moving shortcuts. +* Pinning shortcuts to the taskbar and start menu. +* Flashing a window to get a users attention. ## Shortcut management @@ -36,3 +42,9 @@ Pinning/unpinning a shortcut to the taskbar: C:\blur\dev\casement>casement shortcut pin "C:\Users\Public\Desktop\My Shortcut.lnk" -t C:\blur\dev\casement>casement shortcut unpin "C:\Users\Public\Desktop\My Shortcut.lnk" -t ``` + +# Installing + +Casement can be installed by the standard pip command `pip install casement`. +There is also an optional extras_require option `pip install casement[pil]` to +allow creating shortcut icons by converting image files to .ico files. diff --git a/casement/app_id.py b/casement/app_id.py new file mode 100644 index 0000000..1b14468 --- /dev/null +++ b/casement/app_id.py @@ -0,0 +1,87 @@ +import ctypes +import os +import pythoncom +from ctypes import wintypes +from win32com.propsys import propsys +from win32com.shell import shellcon + + +class AppId(object): + @staticmethod + def for_shortcut(shortcut): + """Gets the AppUserModel.ID for the given shortcut. + + This will allow windows to group windows with the same app id on a shortcut + pinned to the taskbar. Use :py:meth:`AppId.for_application` to set the + app id for a running application. + """ + if os.path.exists(shortcut): + # These imports won't work inside python 2 DCC's + from win32com.propsys import propsys + + # Original info from https://stackoverflow.com/a/61714895 + key = propsys.PSGetPropertyKeyFromName("System.AppUserModel.ID") + store = propsys.SHGetPropertyStoreFromParsingName(shortcut) + return store.GetValue(key).GetValue() + + @staticmethod + def set_for_shortcut(shortcut, app_id): + """Sets AppUserModel.ID info for a windows shortcut. + + Note: This doesn't seem to work on a pinned taskbar shortcut. Set it on + a desktop shortcut then pin that shortcut. + + This will allow windows to group windows with the same app id on a shortcut + pinned to the taskbar. Use :py:meth:`AppId.set_for_application` to set + the app id for a running application. + + Args: + shortcut (str): The .lnk filename to set the app id on. + app_id (str): The app id to set on the shortcut + """ + if os.path.exists(shortcut): + # Original info from https://stackoverflow.com/a/61714895 + key = propsys.PSGetPropertyKeyFromName("System.AppUserModel.ID") + store = propsys.SHGetPropertyStoreFromParsingName( + shortcut, None, shellcon.GPS_READWRITE, propsys.IID_IPropertyStore + ) + + new_value = propsys.PROPVARIANTType(app_id, pythoncom.VT_BSTR) + store.SetValue(key, new_value) + store.Commit() + + @staticmethod + def for_application(): + """Returns the current Windows 7+ app user model id used for taskbar + grouping.""" + + lp_buffer = wintypes.LPWSTR() + app_user_model_id = ( + ctypes.windll.shell32.GetCurrentProcessExplicitAppUserModelID + ) + app_user_model_id(ctypes.cast(ctypes.byref(lp_buffer), wintypes.LPWSTR)) + appid = lp_buffer.value + ctypes.windll.kernel32.LocalFree(lp_buffer) + return appid + + @staticmethod + def set_for_application(appid, prefix=None): + """Controls Windows taskbar grouping. + + Specifies a Explicit App User Model ID that Windows 7+ uses to control + grouping of windows on the taskbar. This must be set before any ui is + displayed. The best place to call it is in the first widget to be displayed + __init__ method. + + See :py:meth:`AppId.set_for_shortcut` to set the app id on a windows shortcut. + + Args: + appid (str): The id of the application. Should use full camel-case. + http://msdn.microsoft.com/en-us/library/dd378459%28v=vs.85%29.aspx#how + prefix (str, optional): The prefix attached to the id. + """ + + # https://stackoverflow.com/a/27872625 + if prefix: + appid = u'%s.%s' % (prefix, appid) + return not ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) diff --git a/casement/shortcut.py b/casement/shortcut.py index 07eb1b9..f9ec063 100644 --- a/casement/shortcut.py +++ b/casement/shortcut.py @@ -57,16 +57,21 @@ C:\>casement shortcut unpin "C:\\Users\\Public\\Desktop\\My Shortcut.lnk" -t """ -import os -import sys -import glob import errno -import win32com.client +import glob import logging +import os import shutil +import six +import subprocess +import sys import tempfile +import win32com.client +import winshell from argparse import ArgumentParser +from .app_id import AppId + class Shortcut(object): default_paths = ( @@ -135,6 +140,11 @@ def _run_verb(self, verb_name): return True return False + @staticmethod + def clear_icon_cache(): + """Triggers a clearing of the windows explorer icon cache.""" + subprocess.Popen(['ie4uinit.exe', '-ClearIconCache']) + def copy(self, target): """Copy this shortcut to/over the target. @@ -178,6 +188,136 @@ def copy(self, target): logging.debug('Copying {} -> {}'.format(self.filename, target)) shutil.copy2(self.filename, target) + @classmethod + def create( + cls, + title, + args, + startin=None, + target=None, + icon=None, + icon_source=None, + icon_filename=None, + path=None, + description='', + common=1, + app_id=None, + ): + """Creates a shortcut. + + Args: + title (str): the title for the shortcut + args (str): argument string to pass to target command + startin (str, optional): path where the shortcut should run target command. + If None(default) then the dirname for the first argument in args + is used. If args is empty, then the dirname of target is used. + target (str, optional): the target for the shortcut. If None(default) + this will default to sys.executable. + icon (str, optional): path to the icon the shortcut should use. + Ignored if icon_source is passed. + icon_source (str, optional): path to a source image file that will + be copied/converted and used as the shortcut icon. The file extension + will be changed to .ico if that file exists. If not a .ico file + Pillow(PIL) will be used to convert the image file to a .ico file + and the .ico file is copied to `%PROGRAMDATA%` if common is 1 + otherwise it will be copied to `%APPDATA%`. These icons will be + stored inside the casement/icons folder. You can install Pillow + when installing casement with 'pip install casement[pil]'. + icon_filename (str, optional): the name of the icon generated by + icon_source. If not specified, title is used. + path (str or list, optional): path where the shortcut should be created. On + windows, if a list is passed it must have at least one string in + it and the list will be passed to os.path.join. The first string + in the list will be replaced if a key is used. `start menu` or + `start-menu` is replaced with the path to the start menu. `desktop` + is replaced with the path to the desktop. If None the desktop + path is used. + description (str, optional): helpful description for the shortcut + common (int, optional): If auto generating the path, this controls + if the path is generated for the user or shared. 1(default) is + the public shared folder, while 0 is the users folder. See path + to control if the auto-generated path is for the desktop or start menu. + app_id (str, optional): Whether to set app ID on shortcut or not + """ + if isinstance(path, (list, tuple)): + base = path[0] + if base in ('start menu', 'start-menu'): + base = os.path.join(winshell.start_menu(common), 'Programs') + elif base == 'desktop': + base = winshell.desktop(common) + # Add the remaining path structure + path = os.path.join(base, *path[1:]) + elif not path: + path = winshell.desktop(common) + + # Create any missing folders in the path structure + if path and not os.path.exists(path): + os.makedirs(path) + + if not target: + target = sys.executable + + if not startin: + # Set the start in directory to the directory of the first args if passed + # otherwise use the target directory + if args: + if isinstance(args, (list, tuple)): + startin = os.path.dirname(args[0]) + else: + startin = os.path.dirname(args) + else: + startin = os.path.dirname(target) + + if icon_source: + # On Windows "PROGRAMDATA" for all users, "APPDATA" for per user. + # See: https://www.microsoft.com/en-us/wdsi/help/folder-variables + dirname = 'PROGRAMDATA' if 1 == common else 'APPDATA' + dirname = os.getenv(dirname) + dirname = os.path.join(dirname, 'casement', 'icons') + if not os.path.exists(dirname): + os.makedirs(dirname) + + output = os.path.abspath( + os.path.join(dirname, (icon_filename or title) + '.ico') + ) + basename, extension = os.path.splitext(icon_source) + ico = basename + '.ico' + if os.path.exists(ico): + shutil.copyfile(ico, output) + else: + from PIL import Image + + Image.open(icon_source).save(output) + icon = output if os.path.exists(output) else None + + shortcut = os.path.join(path, title + '.lnk') + # If the shortcut description is longer than 260 characters, the target may end + # up with random unicode characters, and the icon is not set properly. The + # Properties dialog only shows 259 characters in the description, so we limit + # the description to 259 characters. + description = description[:259] + + # If args is a list, convert it to a string using subprocess + if not isinstance(args, six.string_types): + args = subprocess.list2cmdline(args) + + kwargs = dict( + Arguments=args, + StartIn=startin, + Description=description, + ) + + if icon: + kwargs["Icon"] = (icon, 0) + + winshell.CreateShortcut(shortcut, target, **kwargs) + + if app_id: + AppId.set_for_shortcut(shortcut, app_id) + + # Attempt to clear the windows icon cache so icon changes are displayed now + cls.clear_icon_cache() + @property def filename(self): """The source shortcut filename this class uses as its source.""" @@ -399,11 +539,13 @@ def __init__(self, args=None): description='Pretends to be git', usage="casement shortcut []\n\n" "Valid commands are:\n" - " copy: Copy a shortcut to a new location.\n" - " list: Find all shortcuts in expected locations with a given name.\n" - " move: Rename a shortcut to a given file name and path.\n" - " pin: Pin the shortcut to the current user's start menu and taskbar\n" - " unpin: Un-Pin the shortcut to the current user's start menu and " + " appid: Manage app model id for a shortcut.\n" + " copy: Copy a shortcut to a new location.\n" + " create: Create a shortcut on disk.\n" + " list: Find all shortcuts in expected locations with a given name.\n" + " move: Rename a shortcut to a given file name and path.\n" + " pin: Pin the shortcut to the current user's start menu and taskbar\n" + " unpin: Un-Pin the shortcut to the current user's start menu and " "taskbar", ) self._base_parser.add_argument('command', help='Command to run') @@ -417,6 +559,19 @@ def __init__(self, args=None): # use dispatch pattern to invoke method with same name getattr(self, args.command)() + def appid(self): + """Parse copy command line arguments""" + self.parser = ArgumentParser( + description='View or set the app model id for a shortcut.', + usage='casement shortcut appid [-h] [-v] source', + ) + self.parser.add_argument('source', help='The shortcut to operate on.') + self.parser.add_argument( + '-s', + '--set', + help="Change the shortcut's app id to this.", + ) + def copy(self): """Parse copy command line arguments""" self.parser = ArgumentParser( @@ -430,6 +585,64 @@ def copy(self): 'target', help='Directory or filename to copy source to.' ) + def create(self): + """Parse copy command line arguments""" + self.parser = ArgumentParser( + description='Create a shortcut at the given target.', + usage='casement shortcut create [-h] [-v] title target source', + ) + self.parser.add_argument('title', help='The title for the shortcut.') + self.parser.add_argument( + 'target', help='The target for the shortcut. Defaults to sys.executable.' + ) + self.parser.add_argument( + 'source', + help='File path where the shortcut is created. If you start the path ' + 'with "start-menu" it will be replaced with the path to the start menu. ' + 'Similarly "desktop" will be replaced with the path to the desktop. ' + 'Defaults to desktop.', + ) + self.parser.add_argument( + '--args', default="", help='Argument string to pass to target command.' + ) + self.parser.add_argument( + '-p', + '--public', + action='store_true', + help='If using "start-menu" or "desktop" for path, create the shortcut ' + 'in the public location not the user location. This may require ' + 'administrative privileges.', + ) + self.parser.add_argument( + '--startin', + help='Working directory the target is run from. The dirname of target ' + 'is used if not provided.', + ) + self.parser.add_argument( + '--app-id', + help='Set the app model id of the shortcut allowing it to be used for ' + 'taskbar grouping. See casement.app_id.AppId.set_for_application.', + ) + self.parser.add_argument( + '--icon', help='Path to the icon the shortcut uses for its icon.' + ) + self.parser.add_argument( + '--icon-source', + help='Source image path that is copied/converted. If not a .ico file, ' + 'Pillow will be used to convert the image to a .ico file. The file is ' + 'saved to "casement/icons" in "%%PROGRAMDATA%%" if common is 1 otherwise ' + '"%%APPDATA%%".', + ) + self.parser.add_argument( + '--icon-filename', + help='The .icon filename used by --icon-source. Defaults to title.', + ) + self.parser.add_argument( + '--description', + default="", + help='Helpful description for the shortcut.', + ) + def list(self): """Parse list command line arguments""" self.parser = ArgumentParser( @@ -526,6 +739,29 @@ def run(self, args): for link in links: logging.info(link) return True + elif args.command == 'appid': + if args.set: + AppId.set_for_shortcut(args.source, args.set) + else: + print(AppId.for_shortcut(args.source)) + return True + elif args.command == 'create': + if args.source in ("desktop", "start-menu"): + args.source = [args.source] + Shortcut.create( + args.title, + args.args, + startin=args.startin, + target=args.target, + icon=args.icon, + icon_source=args.icon_source, + icon_filename=args.icon_filename, + path=args.source, + description=args.description, + common=int(args.public), + app_id=args.app_id, + ) + return True shortcut = Shortcut(args.source) if args.command == 'pin': diff --git a/docs/source/conf.py b/docs/source/conf.py index d858dd2..feec685 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,7 +17,8 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # Command to build documentation -# `sphinx-apidoc -o ./docs/source casement tests ./setup.py -e -f && sphinx-build -b html ./docs/source ./docs/build/preview` +# `sphinx-apidoc -o ./docs/source casement tests ./setup.py -e -f && sphinx-build +# -b html ./docs/source ./docs/build/preview` import os import sys diff --git a/setup.cfg b/setup.cfg index e1e6327..67e9bcd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,6 +37,7 @@ project_urls = packages = find: install_requires = pywin32 + winshell>=0.6 python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.* include_package_data = True setup_requires = @@ -61,6 +62,8 @@ dev = pep8-naming pytest tox +pil = + Pillow [bdist_wheel] universal = 1