Skip to content

Commit

Permalink
Add Microsoft Terminal profile shortcut (#200)
Browse files Browse the repository at this point in the history
* Add terminal profile option to Windows schema

* Add Windows Terminal profile installer

* Windows Terminal option is string only

* Add tests

* Add news item

* Explicitly return None if profile location not found

* Skip tests if profile settings file cannot found

* Only import win_utils when platform is win

* Apply suggestions from code review

Co-authored-by: jaimergp <jaimergp@users.noreply.github.com>

* Use indent=2 for test data

* Add profile to all known Windows Terminal locations

* Use typing.List for type declaration

* Return empty list instead of None if profile locations are not found

* Add overwrite warning

* Update menuinst/platforms/win.py

Co-authored-by: jaimergp <jaimergp@users.noreply.github.com>

* Use tmp_path fixture

* Ensure that terminal settings file always contains profiles list

* Install Windows Terminal before running tests

* Fix code formatting

* Create a function for settings.json location to avoid duplication

* Check for parent directory of settings file instead

* Harden against changes in the terminal directory hash

* Ensure that settings parent directory exists

* Mock running as user

Co-authored-by: jaimergp <jaimergp@users.noreply.github.com>

---------

Co-authored-by: jaimergp <jaimergp@users.noreply.github.com>
  • Loading branch information
marcoesters and jaimergp committed May 15, 2024
1 parent 4dfcfe6 commit 416eaf9
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 1 deletion.
6 changes: 6 additions & 0 deletions menuinst/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ class Windows(BasePlatformSpecific):
"Whether to create a desktop icon in addition to the Start Menu item."
quicklaunch: Optional[bool] = True
"Whether to create a quick launch icon in addition to the Start Menu item."
terminal_profile: constr(min_length=1) = None
"""
Name of the Windows Terminal profile to create.
This name must be unique across multiple installations because
menuinst will overwrite Terminal profiles with the same name.
"""
url_protocols: Optional[List[constr(regex=r"\S+")]] = None
"URL protocols that will be associated with this program."
file_extensions: Optional[List[constr(regex=r"\.\S*")]] = None
Expand Down
1 change: 1 addition & 0 deletions menuinst/data/menuinst.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"win": {
"desktop": true,
"quicklaunch": true,
"terminal_profile": null,
"url_protocols": null,
"file_extensions": null,
"app_user_model_id": null
Expand Down
5 changes: 5 additions & 0 deletions menuinst/data/menuinst.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,11 @@
"default": true,
"type": "boolean"
},
"terminal_profile": {
"title": "Terminal Profile",
"minLength": 1,
"type": "string"
},
"url_protocols": {
"title": "Url Protocols",
"type": "array",
Expand Down
69 changes: 68 additions & 1 deletion menuinst/platforms/win.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"""
"""

import json
import os
import shutil
import warnings
from logging import getLogger
from pathlib import Path
from subprocess import CompletedProcess
from tempfile import NamedTemporaryFile
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple

from ..utils import WinLex, logged_run, unlink
from .base import Menu, MenuItem
from .win_utils.knownfolders import folder_path as windows_folder_path
from .win_utils.knownfolders import windows_terminal_settings_files
from .win_utils.registry import (
register_file_extension,
register_url_protocol,
Expand Down Expand Up @@ -64,6 +66,19 @@ def quick_launch_location(self) -> Path:
def desktop_location(self) -> Path:
return Path(windows_folder_path(self.mode, False, "desktop"))

@property
def terminal_profile_locations(self) -> List[Path]:
"""Location of the Windows terminal profiles.
The parent directory is used to check if Terminal is installed
because the settings file is generated when Terminal is opened,
not when it is installed.
"""
if self.mode == "system":
log.warning("Terminal profiles are not available for system level installs")
return []
return windows_terminal_settings_files(self.mode)

@property
def placeholders(self) -> Dict[str, str]:
placeholders = super().placeholders
Expand Down Expand Up @@ -165,6 +180,8 @@ def create(self) -> Tuple[Path, ...]:
self._app_user_model_id(),
)

for location in self.menu.terminal_profile_locations:
self._add_remove_windows_terminal_profile(location, remove=False)
self._register_file_extensions()
self._register_url_protocols()

Expand All @@ -173,6 +190,8 @@ def create(self) -> Tuple[Path, ...]:
def remove(self) -> Tuple[Path, ...]:
self._unregister_file_extensions()
self._unregister_url_protocols()
for location in self.menu.terminal_profile_locations:
self._add_remove_windows_terminal_profile(location, remove=True)

paths = self._paths()
for path in paths:
Expand Down Expand Up @@ -292,6 +311,54 @@ def _process_command(self, with_arg1=False) -> Tuple[str]:
command.append("%1")
return WinLex.quote_args(command)

def _add_remove_windows_terminal_profile(self, location: Path, remove: bool = False):
"""Add/remove the Windows Terminal profile.
Windows Terminal is using the name of the profile to create a GUID,
so the name will be used as the unique identifier to find existing profiles.
If the Terminal app has never been opened, the settings file may not exist yet.
Writing a minimal profile file will not break the application - Terminal will
automatically generate the missing options and profiles without overwriting
the profiles menuinst has created.
"""
if not self.metadata.get("terminal_profile") or not location.parent.exists():
return
name = self.render_key("terminal_profile")

settings = json.loads(location.read_text()) if location.exists() else {}

index = -1
for p, profile in enumerate(settings.get("profiles", {}).get("list", [])):
if profile.get("name") == name:
index = p
break

if remove:
if index < 0:
log.warning(f"Could not find terminal profile for {name}.")
return
del settings["profiles"]["list"][index]
else:
profile_data = {
"commandline": " ".join(WinLex.quote_args(self.render_key("command"))),
"name": name,
}
if self.metadata.get("icon"):
profile_data["icon"] = self.render_key("icon")
if self.metadata.get("working_dir"):
profile_data["startingDirectory"] = self.render_key("working_dir")
if index < 0:
if "profiles" not in settings:
settings["profiles"] = {}
if "list" not in settings["profiles"]:
settings["profiles"]["list"] = []
settings["profiles"]["list"].append(profile_data)
else:
log.warning(f"Overwriting terminal profile for {name}.")
settings["profiles"]["list"][index] = profile_data
location.write_text(json.dumps(settings, indent=4))

def _ftype_identifier(self, extension):
identifier = self.render_key("name", slug=True)
return f"{identifier}.AssocFile{extension}"
Expand Down
32 changes: 32 additions & 0 deletions menuinst/platforms/win_utils/knownfolders.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import os
from ctypes import windll, wintypes
from logging import getLogger
from pathlib import Path
from typing import List
from uuid import UUID

logger = getLogger(__name__)
Expand Down Expand Up @@ -258,6 +260,7 @@ def get_folder_path(folder_id, user=None):
"quicklaunch": get_folder_path(FOLDERID.QuickLaunch),
"documents": get_folder_path(FOLDERID.Documents),
"profile": get_folder_path(FOLDERID.Profile),
"localappdata": get_folder_path(FOLDERID.LocalAppData),
},
}

Expand Down Expand Up @@ -313,3 +316,32 @@ def folder_path(preferred_mode, check_other_mode, key):
)
return None
return path


def windows_terminal_settings_files(mode: str) -> List[Path]:
"""Return all possible locations of the settings.json files for the Windows Terminal.
See the Microsoft documentation for details:
https://learn.microsoft.com/en-us/windows/terminal/install#settings-json-file
"""
if mode != "user":
return []
localappdata = folder_path(mode, False, "localappdata")
packages = Path(localappdata) / "Packages"
profile_locations = [
# Stable
*[
Path(terminal, "LocalState", "settings.json")
for terminal in packages.glob("Microsoft.WindowsTerminal_*")
],
# Preview
*[
Path(terminal, "LocalState", "settings.json")
for terminal in packages.glob("Microsoft.WindowsTerminalPreview_*")
],
]
# Unpackaged (Scoop, Chocolatey, etc.)
unpackaged_path = Path(localappdata, "Microsoft", "Windows Terminal", "settings.json")
if unpackaged_path.parent.exists():
profile_locations.append(unpackaged_path)
return profile_locations
19 changes: 19 additions & 0 deletions news/200-windows-terminal-profile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Enhancements

* Add option to create a Windows Terminal profile. (#196 via #200)

### Bug fixes

* <news item>

### Deprecations

* <news item>

### Docs

* <news item>

### Other

* <news item>
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,10 @@ def osx_base_location(self):
monkeypatch.setattr(LinuxMenu, "_system_data_directory", tmp_path / "data")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))


@pytest.fixture()
def run_as_user(monkeypatch):
from menuinst import utils as menuinst_utils

monkeypatch.setattr(menuinst_utils, "user_is_admin", lambda: False)
36 changes: 36 additions & 0 deletions tests/data/jsons/windows-terminal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"$id": "https://schemas.conda.io/menuinst-1.schema.json",
"menu_name": "Package",
"menu_items": [
{
"name": "A",
"description": "Package A",
"icon": null,
"command": [
"testcommand_a.exe"
],
"platforms": {
"win": {
"desktop": false,
"quicklaunch": false,
"terminal_profile": "A Terminal"
}
}
},
{
"name": "B",
"description": "Package B",
"icon": null,
"command": [
"testcommand_b.exe"
],
"platforms": {
"win": {
"desktop": false,
"quicklaunch": false
}
}
}
]
}
24 changes: 24 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,30 @@ def test_url_protocol_association(delete_files):
)


@pytest.mark.skipif(PLATFORM != "win", reason="Windows only")
def test_windows_terminal_profiles(tmp_path, run_as_user):
settings_file = Path(
tmp_path, "localappdata", "Microsoft", "Windows Terminal", "settings.json"
)
settings_file.parent.mkdir(parents=True)
(tmp_path / ".nonadmin").touch()
metadata_file = DATA / "jsons" / "windows-terminal.json"
install(metadata_file, target_prefix=tmp_path, base_prefix=tmp_path)
try:
settings = json.loads(settings_file.read_text())
profiles = {
profile.get("name", ""): profile.get("commandline", "")
for profile in settings.get("profiles", {}).get("list", [])
}
assert profiles.get("A Terminal") == "testcommand_a.exe"
assert "B" not in profiles
except Exception as exc:
remove(metadata_file, target_prefix=tmp_path, base_prefix=tmp_path)
raise exc
else:
remove(metadata_file, target_prefix=tmp_path, base_prefix=tmp_path)


@pytest.mark.parametrize("target_env_is_base", (True, False))
def test_name_dictionary(target_env_is_base):
tmp_base_path = mkdtemp()
Expand Down

0 comments on commit 416eaf9

Please sign in to comment.