diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 05ea0d46..9ded1036 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -80,7 +80,7 @@ jobs: - shell: bash -el {0} name: Run test suite run: | - python -I -m pytest tests/ --cov-append --cov-report=xml --cov=menuinst + python -I -m pytest tests/ --cov-append --cov-report=xml --cov=menuinst -vvv - uses: codecov/codecov-action@v1 with: diff --git a/menuinst/_schema.py b/menuinst/_schema.py index 4239df97..522c6ef6 100644 --- a/menuinst/_schema.py +++ b/menuinst/_schema.py @@ -74,6 +74,16 @@ class Windows(BasePlatformSpecific): "URL protocols that will be associated with this program." file_extensions: Optional[List[constr(regex=r"\.\S*")]] = None "File extensions that will be associated with this program." + app_user_model_id: Optional[constr(regex=r"\S+\.\S+", max_length=128)] = None + """ + Identifier used in Windows 7 and above to associate processes, files and windows with a + particular application. If your shortcut produces duplicated icons, you need to define this + field. If not set, it will default to ``Menuinst.``. + + See `AppUserModelID docs `__ for more information on the required string format. + + .. aumi-docs: https://learn.microsoft.com/en-us/windows/win32/shell/appids#how-to-form-an-application-defined-appusermodelid + """ class Linux(BasePlatformSpecific): diff --git a/menuinst/data/menuinst.default.json b/menuinst/data/menuinst.default.json index 586546a1..b220ad39 100644 --- a/menuinst/data/menuinst.default.json +++ b/menuinst/data/menuinst.default.json @@ -57,7 +57,8 @@ "desktop": true, "quicklaunch": true, "url_protocols": null, - "file_extensions": null + "file_extensions": null, + "app_user_model_id": null } } } diff --git a/menuinst/data/menuinst.schema.json b/menuinst/data/menuinst.schema.json index 85476aed..b76b8ba7 100644 --- a/menuinst/data/menuinst.schema.json +++ b/menuinst/data/menuinst.schema.json @@ -581,6 +581,12 @@ "type": "string", "pattern": "\\.\\S*" } + }, + "app_user_model_id": { + "title": "App User Model Id", + "maxLength": 128, + "pattern": "\\S+\\.\\S+", + "type": "string" } }, "additionalProperties": false diff --git a/menuinst/platforms/win.py b/menuinst/platforms/win.py index cb5612e0..d3f88ff4 100644 --- a/menuinst/platforms/win.py +++ b/menuinst/platforms/win.py @@ -152,7 +152,7 @@ def create(self) -> Tuple[Path, ...]: # winshortcut is a windows-only C extension! create_shortcut has this API # Notice args must be passed as positional, no keywords allowed! # winshortcut.create_shortcut(path, description, filename, arguments="", - # workdir=None, iconpath=None, iconindex=0) + # workdir=None, iconpath=None, iconindex=0, app_id="") create_shortcut( target_path, self._shortcut_filename(ext=""), @@ -160,6 +160,8 @@ def create(self) -> Tuple[Path, ...]: " ".join(arguments), working_dir, icon, + 0, + self._app_user_model_id(), ) self._register_file_extensions() @@ -389,3 +391,9 @@ def _unregister_url_protocols(self): for protocol in protocols: identifier = self._ftype_identifier(protocol) unregister_url_protocol(protocol, identifier, mode=self.menu.mode) + + def _app_user_model_id(self): + aumi = self.render_key("app_user_model_id") + if not aumi: + return f"Menuinst.{self.render_key('name', slug=True).replace('.', '')}"[:128] + return aumi diff --git a/news/133-app-user-model-id b/news/133-app-user-model-id new file mode 100644 index 00000000..5e3a7de4 --- /dev/null +++ b/news/133-app-user-model-id @@ -0,0 +1,19 @@ +### Enhancements + +* Add `app_user_model_id` field on Windows shortcuts to group taskbar icons together. (#127 via #133) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/src/winshortcut.cpp b/src/winshortcut.cpp index 72f00fdf..0be6e136 100644 --- a/src/winshortcut.cpp +++ b/src/winshortcut.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include "resource.h" #include @@ -38,10 +40,13 @@ static PyObject *CreateShortcut(PyObject *self, PyObject *args) Py_UNICODE *iconpath = NULL; int iconindex = 0; Py_UNICODE *workdir = NULL; + Py_UNICODE *app_id = NULL; IShellLink *pShellLink = NULL; IPersistFile *pPersistFile = NULL; + IPropertyStore *pPropertyStore = NULL; + PROPVARIANT pv; HRESULT hres; hres = CoInitialize(NULL); @@ -51,9 +56,9 @@ static PyObject *CreateShortcut(PyObject *self, PyObject *args) goto error; } - if (!PyArg_ParseTuple(args, "uuu|uuui", + if (!PyArg_ParseTuple(args, "uuu|uuuiu", &path, &description, &filename, - &arguments, &workdir, &iconpath, &iconindex)) { + &arguments, &workdir, &iconpath, &iconindex, &app_id)) { return NULL; } @@ -116,6 +121,25 @@ static PyObject *CreateShortcut(PyObject *self, PyObject *args) } } + if (app_id) { + hres = pShellLink->QueryInterface(IID_PPV_ARGS(&pPropertyStore)); + if (FAILED(hres)) { + PyErr_Format(PyExc_OSError, + "QueryInterface(IPropertyStore) error 0x%x", hres); + goto error; + } + hres = InitPropVariantFromString(app_id, &pv); + if (FAILED(hres)) { + PyErr_Format(PyExc_OSError, + "InitPropVariantFromString() error 0x%x", hres); + goto error; + } + pPropertyStore->SetValue(PKEY_AppUserModel_ID, pv); + pPropertyStore->Commit(); + PropVariantClear(&pv); + pPropertyStore->Release(); + } + hres = pPersistFile->Save(filename, TRUE); if (FAILED(hres)) { PyObject *fn = PyUnicode_FromWideChar(filename, wcslen(filename)); @@ -145,6 +169,9 @@ static PyObject *CreateShortcut(PyObject *self, PyObject *args) if (pShellLink) { pShellLink->Release(); } + if (pPropertyStore) { + pPropertyStore->Release(); + } CoUninitialize(); return NULL;