diff --git a/docs/jobs/default_profile_setter.md b/docs/jobs/default_profile_setter.md index 9f1dad27..c565e133 100644 --- a/docs/jobs/default_profile_setter.md +++ b/docs/jobs/default_profile_setter.md @@ -16,6 +16,18 @@ Sample job configuration in your scenario file: force_profile_selection_policy: true ``` +On Windows, if you want to ensure that double-clicking on QGIS Project files will launch QGIS with the chosen default profile and disable version checking: + +```yaml +- name: Set default profile to conf_qgis_fr + uses: default-profile-setter + with: + profile: conf_qgis_fr + force_profile_selection_policy: true + force_profile_file_association: true + profile_file_association_arguments: "--noversioncheck" +``` + ---- ## Options @@ -29,6 +41,22 @@ Name of the profile to set as default profile. Force the key `selectionPolicy` to 1, which will always open the profile defined in the `defaultProfile` key in `profiles.ini` file. In this context, this job will force QGIS to always start with the profile specified in this job. It's the same behavior as [the option _Always use profile_ in QGIS user profiles preferences](https://docs.qgis.org/latest/en/docs/user_manual/introduction/qgis_configuration.html#setting-user-profile). +### force_profile_file_association + +:::{note} +Windows-only feature +::: + +Modify the Windows registry to ensure that QGIS Project files always open with the default profile when launched. + +### profile_file_association_arguments + +:::{note} +Windows-only feature +::: + +Arguments to pass to QGIS executable. + ---- ## Schema diff --git a/docs/schemas/scenario/jobs/default-profile-setter.json b/docs/schemas/scenario/jobs/default-profile-setter.json index 8c3dfe6b..675407a7 100644 --- a/docs/schemas/scenario/jobs/default-profile-setter.json +++ b/docs/schemas/scenario/jobs/default-profile-setter.json @@ -13,6 +13,24 @@ "description": "Force the profile selection policy to 1 (always open default profile).", "type": "boolean", "default": false + }, + "force_profile_file_association": { + "description": "Force the QGIS project file association to starts QGIS with the default profile.", + "type": "boolean", + "default": false + }, + "profile_file_association_arguments": { + "description": "Arguments to pass to QGIS executable when force_profile_file_association is true.", + "type": "string", + "example": "--noversioncheck" + }, + "force_registry_key_creation": { + "description": "Force the creation of the registry key for the default profile. This is useful when QGIS is not installed yet.", + "type": "boolean", + "default": false } - } + }, + "required": [ + "profile" + ] } diff --git a/qgis_deployment_toolbelt/jobs/job_default_profile_setter.py b/qgis_deployment_toolbelt/jobs/job_default_profile_setter.py index f85eb678..70844aa1 100644 --- a/qgis_deployment_toolbelt/jobs/job_default_profile_setter.py +++ b/qgis_deployment_toolbelt/jobs/job_default_profile_setter.py @@ -13,10 +13,12 @@ # Standard library import logging +from sys import platform as opersys # package from qgis_deployment_toolbelt.jobs.generic_job import GenericJob from qgis_deployment_toolbelt.utils.ini_parser_with_path import CustomConfigParser +from qgis_deployment_toolbelt.utils.win32utils import set_qgis_command # ############################################################################# # ########## Globals ############### @@ -52,6 +54,29 @@ class JobDefaultProfileSetter(GenericJob): "possible_values": None, "condition": None, }, + "force_profile_file_association": { + "type": bool, + "required": False, + "default": False, + "possible_values": None, + "condition": None, + }, + "profile_file_association_arguments": { + "type": str, + "required": False, + "default": None, + "possible_values": None, + "condition": lambda options: options.get("force_profile_file_association") + is True, + }, + "force_registry_key_creation": { + "type": bool, + "required": False, + "default": False, + "possible_values": None, + "condition": lambda options: options.get("force_profile_file_association") + is True, + }, } def __init__(self, options: dict) -> None: @@ -81,32 +106,61 @@ def run(self) -> None: logger.error("No QGIS profile matching the provided profile name.") return - ini_profiles_path = self.qgis_profiles_path / "profiles.ini" + self.ini_profiles_path = self.qgis_profiles_path / "profiles.ini" # check if the profiles.ini file exists and create it with default profile set # if not - if not ini_profiles_path.exists(): - logger.warning( - "Configuration file profiles.ini doesn't exist. " - "It will be created but maybe it was not the expected behavior." + if not self.ini_profiles_path.exists(): + self._create_ini_profiles() + else: + self._alter_ini_profiles() + + if self.options.get("force_profile_file_association"): + qgis_cmd = ( + f'"{self.os_config.get_qgis_bin_path}" --profile "{qdt_profile.name}"' ) - ini_profiles_path.touch(exist_ok=True) + if self.options.get("profile_file_association_arguments"): + qgis_cmd += f' {self.options.get("profile_file_association_arguments")}' + qgis_cmd += ' "%1"' + logger.debug(f"Command to set file association: {qgis_cmd}") + if opersys == "win32": + command_setted = set_qgis_command( + qgis_cmd=qgis_cmd, + force_key_creation=self.options.get( + "force_registry_key_creation", False + ), + ) + if not command_setted: + logger.error( + "Failed to set file association for the default profile." + ) + else: + logger.warning( + "File association is only supported on Windows. " + "Skipping file association setting." + ) + + logger.debug(f"Job {self.ID} ran successfully.") + + def _create_ini_profiles(self) -> None: + """Create the profiles.ini file with the default profile.""" + if not self.ini_profiles_path.exists(): + self.ini_profiles_path.touch(exist_ok=True) data = f"[core]\ndefaultProfile={self.options.get('profile')}" if self.options.get("force_profile_selection_policy"): data += "\nselectionPolicy=1" - ini_profiles_path.write_text( + self.ini_profiles_path.write_text( data=data, encoding="UTF8", ) logger.info(f"Default profile set to {self.options.get('profile')}") - logger.debug(f"Job {self.ID} ran successfully.") - return + def _alter_ini_profiles(self) -> None: + """Alter the ini profiles with the default profile.""" ini_profiles = CustomConfigParser() ini_profiles.optionxform = str ini_profiles.read(self.qgis_profiles_path / "profiles.ini", encoding="UTF8") - # set the default profile if not ini_profiles.has_section("core"): ini_profiles.add_section("core") @@ -122,8 +176,6 @@ def run(self) -> None: ) ini_profiles.set("core", "selectionPolicy", "1") - with ini_profiles_path.open("w", encoding="UTF8") as wf: + with self.ini_profiles_path.open("w", encoding="UTF8") as wf: ini_profiles.write(wf, space_around_delimiters=False) logger.info(f"Default profile set to {self.options.get('profile')}") - - logger.debug(f"Job {self.ID} ran successfully.") diff --git a/qgis_deployment_toolbelt/utils/win32utils.py b/qgis_deployment_toolbelt/utils/win32utils.py index d1f2c87c..dbdc4894 100644 --- a/qgis_deployment_toolbelt/utils/win32utils.py +++ b/qgis_deployment_toolbelt/utils/win32utils.py @@ -20,6 +20,7 @@ from os import sep # required since pathlib strips trailing whitespace from pathlib import Path from sys import platform as opersys +from typing import Literal # Imports depending on operating system if opersys == "win32": @@ -39,11 +40,19 @@ if opersys == "win32": """windows""" - system_hkey = ( + env_system_hkey = ( winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", ) - user_hkey = (winreg.HKEY_CURRENT_USER, r"Environment") + env_user_hkey = (winreg.HKEY_CURRENT_USER, r"Environment") + qgis_command_system_hkey = ( + winreg.HKEY_LOCAL_MACHINE, + r"QGIS Project\Shell\open\command", + ) + qgis_command_user_hkey = ( + winreg.HKEY_CURRENT_USER, + r"Software\Classes\QGIS Project\Shell\open\command", + ) # ############################################################################# @@ -115,9 +124,9 @@ def delete_environment_variable(envvar_name: str, scope: str = "user") -> bool: """ # user or system if scope == "user": - hkey = user_hkey + hkey = env_user_hkey else: - hkey = system_hkey + hkey = env_system_hkey # get it to check if variable exits try: @@ -150,9 +159,9 @@ def get_environment_variable(envvar_name: str, scope: str = "user") -> str | Non """ # user or system if scope == "user": - hkey = user_hkey + hkey = env_user_hkey else: - hkey = system_hkey + hkey = env_system_hkey # try to get the value try: @@ -281,9 +290,9 @@ def set_environment_variable( """ # user or system if scope == "user": - hkey = user_hkey + hkey = env_user_hkey else: - hkey = system_hkey + hkey = env_system_hkey # try to set the value try: @@ -298,6 +307,79 @@ def set_environment_variable( return False +def read_registry_value( + key: tuple, value_name: str, access_mode: Literal["read", "write"] = "read" +) -> str | None: + r"""Read a value from the Windows registry. + Args: + key (tuple): registry key to read from, e.g. (winreg.HKEY_CURRENT_USER, r"Software\Classes\QGIS Project\Shell\open\command") + value_name (str): name of the value to read + access (str, optional): access mode for the registry key, defaults to read + Returns: + str | None: the value as a string if found, None if not found or an error occurs + """ + if access_mode == "read": + access = winreg.KEY_READ + elif access_mode == "write": + access = winreg.KEY_WRITE + + try: + with winreg.OpenKey(*key, access=access) as reg_key: + value, _ = winreg.QueryValueEx(reg_key, value_name) + return value + except FileNotFoundError: + logger.error(f"Registry key {key} or value {value_name} not found.") + return None + except OSError as err: + logger.error(f"Error reading registry key {key}: {err}") + return None + + +def set_qgis_command( + qgis_cmd: str, scope: str = "user", force_key_creation: bool = False +) -> bool: + """Set QGIS command in Windows registry. + + Args: + qgis_path (str): path to QGIS installation folder + scope (str, optional): environment variable scope. Must be "user" or "system", + defaults to "user". Defaults to "user". + + Returns: + bool: True is the variable has been successfully set + """ + # user or system + if scope == "user": + hkey = qgis_command_user_hkey + else: + hkey = qgis_command_system_hkey + + if force_key_creation: + # ensure the key exists + try: + with winreg.CreateKeyEx(*hkey, access=winreg.KEY_WRITE) as key: + pass # just create the key if it does not exist + except OSError as err: + logger.error( + f"Create QGIS command registry key for scope '{scope}' failed. Trace: {err}" + ) + return False + + # try to set the value + try: + with winreg.OpenKey(*hkey, access=winreg.KEY_WRITE) as key: + winreg.SetValueEx(key, "", 0, winreg.REG_SZ, qgis_cmd) + return True + except FileNotFoundError: + logger.error(f"Registry key {hkey} not found. Is QGIS installed?") + return False + except OSError as err: + logger.error( + f"Set QGIS command '{qgis_cmd}' to scope '{scope}' failed. Trace: {err}" + ) + return False + + # ############################################################################# # ##### Stand alone program ######## # ################################## diff --git a/tests/test_job_default_profile_setter.py b/tests/test_job_default_profile_setter.py index 675d6c84..28124c43 100644 --- a/tests/test_job_default_profile_setter.py +++ b/tests/test_job_default_profile_setter.py @@ -17,13 +17,21 @@ # Standard library import tempfile import unittest +from os import environ, getenv from pathlib import Path +from sys import platform as opersys # package from qgis_deployment_toolbelt.jobs.job_default_profile_setter import ( JobDefaultProfileSetter, ) +if opersys == "win32": + from qgis_deployment_toolbelt.utils.win32utils import ( + qgis_command_user_hkey, + read_registry_value, + ) + # ############################################################################# # ########## Classes ############### @@ -115,3 +123,50 @@ def test_job_default_profile_setter_run_with_force_profile_selection_policy(self "[core]\ndefaultProfile=qdt_test_profile_minimal\nselectionPolicy=1\n\n" ) self.assertEqual(content, expected_content) + + @unittest.skipUnless( + opersys == "win32" and getenv("GITHUB_ACTIONS", False), + "Test specific to Windows and run only in CI to avoid modifying registry.", + ) + def test_job_default_profile_setter_run_with_force_profile_file_association(self): + """Run the job with force profile file association.""" + self.default_profile_setter_job.options["force_profile_file_association"] = True + self.default_profile_setter_job.options["force_registry_key_creation"] = True + environ["QDT_QGIS_EXE_PATH"] = "/usr/bin/toto" + + self.default_profile_setter_job.run() + self.assertTrue(self.profiles_ini.exists()) + + reg_value = read_registry_value( + qgis_command_user_hkey, + "", + ) + expected_reg_value = ( + '"\\usr\\bin\\toto" --profile "qdt_test_profile_minimal" "%1"' + ) + self.assertEqual(reg_value, expected_reg_value) + + @unittest.skipUnless( + opersys == "win32" and getenv("GITHUB_ACTIONS", False), + "Test specific to Windows and run only in CI to avoid modifying registry.", + ) + def test_job_default_profile_setter_run_with_profile_file_association_arguments( + self, + ): + """Run the job with force profile file association.""" + self.default_profile_setter_job.options["force_profile_file_association"] = True + self.default_profile_setter_job.options["force_registry_key_creation"] = True + self.default_profile_setter_job.options[ + "profile_file_association_arguments" + ] = "--noversioncheck" + environ["QDT_QGIS_EXE_PATH"] = "/usr/bin/toto" + + self.default_profile_setter_job.run() + self.assertTrue(self.profiles_ini.exists()) + + reg_value = read_registry_value( + qgis_command_user_hkey, + "", + ) + expected_reg_value = '"\\usr\\bin\\toto" --profile "qdt_test_profile_minimal" --noversioncheck "%1"' + self.assertEqual(reg_value, expected_reg_value) diff --git a/tests/test_utils_win32.py b/tests/test_utils_win32.py index fc8b5c25..35d4f2d9 100644 --- a/tests/test_utils_win32.py +++ b/tests/test_utils_win32.py @@ -14,8 +14,17 @@ import unittest from sys import platform as opersys +# Imports depending on operating system +if opersys == "win32": + """windows""" + # standard + import winreg + # project -from qgis_deployment_toolbelt.utils.win32utils import get_environment_variable +from qgis_deployment_toolbelt.utils.win32utils import ( + get_environment_variable, + read_registry_value, +) # ############################################################################ # ########## Classes ############# @@ -34,6 +43,32 @@ def test_win32_getenv(self): # KO self.assertIsNone(get_environment_variable("YOUPI")) + @unittest.skipUnless(opersys == "win32", "Test specific to Windows.") + def test_win32_read_registry_value(self): + """Test specific Windows registry value reader.""" + # OK + self.assertIsInstance( + read_registry_value( + ( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", + ), + "ProductName", + ), + str, + ) + + # KO + self.assertIsNone( + read_registry_value( + ( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Explorer", + ), + "NonExistentKey", + ) + ) + # ############################################################################ # ####### Stand-alone run ########