Skip to content

Commit

Permalink
Merge branch 'main' into osx-app-overwrite
Browse files Browse the repository at this point in the history
  • Loading branch information
marcoesters committed May 16, 2024
2 parents 1ba8600 + 416eaf9 commit d471689
Show file tree
Hide file tree
Showing 17 changed files with 226 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/cla.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check CLA
uses: conda/actions/check-cla@1e442e090ad28c9b0f85697105703a303320ffd1 # v24.4.0
uses: conda/actions/check-cla@976289d0cfd85139701b26ddd133abdd025a7b5f # v24.5.0
with:
# [required]
# A token with ability to comment, label, and modify the commit status
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
run:
shell: bash -el {0}
steps:
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b #v4.1.4
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b #v4.1.5

- uses: conda-incubator/setup-miniconda@a4260408e20b96e80095f42ff7f1a15b27dd94ca #v3.0.4
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
GLOBAL: https://raw.githubusercontent.com/conda/infra/main/.github/global.yml
LOCAL: .github/labels.yml
steps:
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
- id: has_local
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
days-before-issue-stale: 90
days-before-issue-close: 21
steps:
- uses: conda/actions/read-yaml@1e442e090ad28c9b0f85697105703a303320ffd1 # v24.4.0
- uses: conda/actions/read-yaml@976289d0cfd85139701b26ddd133abdd025a7b5f # v24.5.0
id: read_yaml
with:
path: https://raw.githubusercontent.com/conda/infra/main/.github/messages.yml
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
python-version: "3.9"

steps:
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b #v4.1.4
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b #v4.1.5
with:
fetch-depth: 0

Expand Down Expand Up @@ -120,7 +120,7 @@ jobs:
steps:
# Clean checkout of specific git ref needed for package metadata version
# which needs env vars GIT_DESCRIBE_TAG and GIT_BUILD_STR:
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b #v4.1.4
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b #v4.1.5
with:
ref: ${{ github.ref }}
clean: true
Expand Down Expand Up @@ -153,7 +153,7 @@ jobs:
Path(environ["GITHUB_ENV"]).write_text(f"ANACONDA_ORG_LABEL={label}")
- name: Create and upload canary build
uses: conda/actions/canary-release@1e442e090ad28c9b0f85697105703a303320ffd1 #v24.4.0
uses: conda/actions/canary-release@976289d0cfd85139701b26ddd133abdd025a7b5f #v24.5.0
env:
# Run conda-build in isolated activation to properly package conda
_CONDA_BUILD_ISOLATED_ACTIVATION: 1
Expand Down
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 @@ -167,6 +182,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 @@ -175,6 +192,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 @@ -294,6 +313,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
2 changes: 1 addition & 1 deletion menuinst/platforms/win_utils/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def register_file_extension(extension, identifier, command, icon=None, mode="use
log.debug("Created registry entry for command '%s'", command)

if icon:
subkey = winreg.OpenKey(key, identifier)
subkey = winreg.OpenKey(key, identifier, access=winreg.KEY_SET_VALUE)
winreg.SetValueEx(subkey, "DefaultIcon", 0, winreg.REG_SZ, icon)
log.debug("Created registry entry for icon '%s'", icon)

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>
19 changes: 19 additions & 0 deletions news/206-file-extensions-regkey-access
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Enhancements

* <news item>

### Bug fixes

* Fix Windows registry key access mode when adding icon file to file type association. (#191 via #206)

### 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 @@ -79,3 +79,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)
1 change: 1 addition & 0 deletions tests/data/jsons/file_types.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
],
"platforms": {
"win": {
"icon": "doesnotexistbutitsok.{{ ICON_EXT }}",
"command": [
"{{ PYTHON }}",
"-c",
Expand Down
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 @@ -345,6 +345,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 d471689

Please sign in to comment.