Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Microsoft Terminal profile shortcut #200

Merged
merged 37 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3555cd2
Add terminal profile option to Windows schema
marcoesters May 1, 2024
575e4e2
Add Windows Terminal profile installer
marcoesters May 1, 2024
92e57dc
Windows Terminal option is string only
marcoesters May 2, 2024
c4079b0
Add tests
marcoesters May 2, 2024
4f0907b
Add news item
marcoesters May 2, 2024
b509fde
Explicitly return None if profile location not found
marcoesters May 2, 2024
9bdd4f3
Skip tests if profile settings file cannot found
marcoesters May 2, 2024
6f9d6f0
Only import win_utils when platform is win
marcoesters May 2, 2024
b87298c
Apply suggestions from code review
marcoesters May 6, 2024
ebe0c6f
Use indent=2 for test data
marcoesters May 6, 2024
b1944f1
Add profile to all known Windows Terminal locations
marcoesters May 7, 2024
fda26f0
Use typing.List for type declaration
marcoesters May 7, 2024
7d19bb3
Return empty list instead of None if profile locations are not found
marcoesters May 7, 2024
cf9ebc2
Add overwrite warning
marcoesters May 7, 2024
448fddf
Update menuinst/platforms/win.py
marcoesters May 8, 2024
9d36e8b
Use tmp_path fixture
marcoesters May 8, 2024
4f6d849
Ensure that terminal settings file always contains profiles list
marcoesters May 8, 2024
a3cd0cf
Install Windows Terminal before running tests
marcoesters May 8, 2024
df5cb19
Merge branch 'main' into windows-terminal-profile
marcoesters May 8, 2024
016e16a
Fix code formatting
marcoesters May 8, 2024
a37bb09
Create a function for settings.json location to avoid duplication
marcoesters May 9, 2024
f1983b0
Check for parent directory of settings file instead
marcoesters May 9, 2024
d57865f
Consolidate calls to get LocalAppData directory
marcoesters May 9, 2024
e8f128c
Do not mock locations on CI for terminal profile test
marcoesters May 9, 2024
7fd05bf
Replace deprecated warn with warning
marcoesters May 9, 2024
20eebae
Merge branch 'main' of github.com:conda/menuinst into windows-termina…
marcoesters May 9, 2024
21a9de9
Debug: output packages directory after installing terminal
marcoesters May 9, 2024
b5c0fb9
Debug: output of microsoft directory after installing terminal
marcoesters May 9, 2024
3f822c4
Debug: output path of terminal and start it
marcoesters May 9, 2024
ce10659
Debug: fix typo
marcoesters May 9, 2024
ab06c18
Revert debugging
marcoesters May 9, 2024
0d29a9b
Harden against changes in the terminal directory hash
marcoesters May 9, 2024
e554194
Replace deprecated warn with warning
marcoesters May 9, 2024
b5a4191
Ensure that settings parent directory exists
marcoesters May 13, 2024
80984e9
Only use mocked location for testing
marcoesters May 13, 2024
857ff4d
Mock running as user
marcoesters May 13, 2024
981f2cd
Update tests/conftest.py
marcoesters May 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions menuinst/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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 @@ -56,6 +56,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 @@ -566,6 +566,11 @@
"default": true,
"type": "boolean"
},
"terminal_profile": {
"title": "Terminal Profile",
"minLength": 1,
"type": "string"
},
"url_protocols": {
"title": "Url Protocols",
"type": "array",
Expand Down
83 changes: 82 additions & 1 deletion menuinst/platforms/win.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""
"""

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
Expand Down Expand Up @@ -64,6 +65,43 @@ 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.

See the Microsoft documentation for details:
https://learn.microsoft.com/en-us/windows/terminal/install#settings-json-file
"""
if self.mode == "system":
log.warn("Terminal profiles are not available for system level installs")
return []
profile_locations = [
# Stable
Path(
windows_folder_path(self.mode, False, "localappdata"),
"Packages",
"Microsoft.WindowsTerminal_8wekyb3d8bbwe",
"LocalState",
"settings.json",
),
# Preview
Path(
windows_folder_path(self.mode, False, "localappdata"),
"Packages",
"Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe",
"LocalState",
"settings.json",
),
# Unpackaged (Scoop, Chocolatey, etc.)
Path(
windows_folder_path(self.mode, False, "localappdata"),
"Microsoft",
"Windows Terminal",
"settings.json",
),
]
return [location for location in profile_locations if location.exists()]

@property
def placeholders(self) -> Dict[str, str]:
placeholders = super().placeholders
Expand Down Expand Up @@ -165,6 +203,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 +213,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 @@ -293,6 +335,45 @@ 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 not self.metadata.get("terminal_profile") or not location.exists():
return
name = self.render_key("terminal_profile")

settings = json.loads(location.read_text())

index = -1
for p, profile in enumerate(settings["profiles"]["list"]):
marcoesters marked this conversation as resolved.
Show resolved Hide resolved
if profile.get("name") == name:
index = p
break

if remove:
if index < 0:
log.warn(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:
settings["profiles"]["list"].append(profile_data)
else:
log.warn(f"Overwriting terminal profile for {name}.")
settings["profiles"]["list"][index] = profile_data
location.write_text(json.dumps(settings, indent=4))
jaimergp marked this conversation as resolved.
Show resolved Hide resolved

def _ftype_identifier(self, extension):
identifier = self.render_key("name", slug=True)
return f"{identifier}.AssocFile{extension}"
Expand Down
1 change: 1 addition & 0 deletions menuinst/platforms/win_utils/knownfolders.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,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
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>
36 changes: 36 additions & 0 deletions tests/data/jsons/windows-terminal.json
marcoesters marked this conversation as resolved.
Show resolved Hide resolved
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
}
}
}
]
}
61 changes: 61 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
from menuinst.platforms.osx import _lsregister
from menuinst.utils import DEFAULT_PREFIX, logged_run

if PLATFORM == "win":
from menuinst.platforms.win_utils.knownfolders import folder_path


def _poll_for_file_contents(path, timeout=10):
t0 = time()
Expand Down Expand Up @@ -294,3 +297,61 @@ def test_url_protocol_association(delete_files):
url_to_open=url,
expected_output=url,
)


@pytest.mark.skipif(PLATFORM != "win", reason="Windows only")
def test_windows_terminal_profiles():
marcoesters marked this conversation as resolved.
Show resolved Hide resolved
def _parse_terminal_profiles(settings_file: Path) -> dict:
settings = json.loads(settings_file.read_text())
return {
profile.get("name", ""): profile.get("commandline", "")
for profile in settings["profiles"]["list"]
}

settings_files = [
# Stable
Path(
folder_path("user", False, "localappdata"),
"Packages",
"Microsoft.WindowsTerminal_8wekyb3d8bbwe",
"LocalState",
"settings.json",
),
# Preview
Path(
folder_path("user", False, "localappdata"),
"Packages",
"Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe",
"LocalState",
"settings.json",
),
# Unpackaged (Scoop, Chocolatey, etc.)
Path(
folder_path("user", False, "localappdata"),
"Microsoft",
"Windows Terminal",
"settings.json",
),
]
jaimergp marked this conversation as resolved.
Show resolved Hide resolved
settings_files = [file for file in settings_files if file.exists()]
if not settings_files:
pytest.skip("No terminal profile settings file found.")
jaimergp marked this conversation as resolved.
Show resolved Hide resolved
tmpdir = mkdtemp()
(Path(tmpdir) / ".nonadmin").touch()
jaimergp marked this conversation as resolved.
Show resolved Hide resolved
metadata_file = DATA / "jsons" / "windows-terminal.json"
install(metadata_file, target_prefix=tmpdir, base_prefix=tmpdir)
a_in_profiles = []
b_in_profiles = []
try:
for file in settings_files:
profiles = _parse_terminal_profiles(file)
if "A Terminal" in profiles and profiles["A Terminal"] == "testcommand_a.exe":
a_in_profiles.append(file)
if "B" in profiles:
b_in_profiles.append(file)
assert a_in_profiles == settings_files and b_in_profiles == []
except Exception as exc:
remove(metadata_file, target_prefix=tmpdir, base_prefix=tmpdir)
raise exc
else:
remove(metadata_file, target_prefix=tmpdir, base_prefix=tmpdir)
Loading