Skip to content

Commit

Permalink
Cross-platform support for URL and file type shortcut association (Wi…
Browse files Browse the repository at this point in the history
…ndows) (#117)

* draft support for registering file extensions on Windows

* update schema

* return early

* propose registry based approach for file extensions

* draft for protocols too

* refactor a bit

* test file extensions

* different skip strategy

* /f goes at the end

* quiet, debug

* fix logging calls

* update test

* fix tests and make them cleaner

* add some docs (WIP)

* more docs

* unneeded import

* fix docs

* add news
  • Loading branch information
jaimergp authored Mar 3, 2023
1 parent 5dc44bf commit 0684438
Show file tree
Hide file tree
Showing 8 changed files with 544 additions and 22 deletions.
102 changes: 102 additions & 0 deletions docs/source/defining-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,105 @@ The full list of available placeholders is available at {ref}`placeholders`.
This is not using any customization options or advanced features.
It's the bare minimum to make it work: a name, the command, and the target platforms.

## Associate your shortcut with file types and URL protocols

### File types

Each operating system has a slightly different way of associating a file type to a given shortcut.
Unix systems have the notion of MIME types, while Windows relies more on file name extensions.

* On Linux, use the `MimeType` option.
Remember to add the `%f` (single file) or `%F` (several files) placeholders to your command
so the URLs are passed adequately.
* On macOS, use `CFBundleDocumentTypes`. Requires no placeholder. The opened document will be automatically passed as an argument.
* On Windows, use `file_extensions`. Remember to add the `%1` or `%*` placeholders to your command
so the path of the opened file(s) is passed adequately.


A multi-platform example:

```json
{
"$schema": "https://json-schema.org/draft-07/schema",
"$id": "https://schemas.conda.io/menuinst-1.schema.json",
"menu_name": "File type handler example",
"menu_items": [
{
"name": "My CSV Reader",
"activate": true,
"command": ["{{ PREFIX }}/bin/my_csv_reader.py"],
"icon": "{{ MENU_DIR }}/my_csv_reader.{{ ICON_EXT }}",
"platforms": {
"linux": {
"command": ["{{ PREFIX }}/bin/my_csv_reader.py", "%f"],
"MimeType": ["text/csv"]
},
"macos": {
"CFBundleDocumentTypes": [
{
"CFBundleTypeIconFile": "{{ MENU_DIR }}/my_csv_reader",
"CFBundleTypeName": "my-csv-reader.csv",
"CFBundleTypeRole": "Viewer",
"LSItemContentTypes": ["public.comma-separated-values-text"],
"LSHandlerRank": "Default"
}
]
},
"windows": {
"command": ["{{ SCRIPTS_DIR }}/my_csv_reader.py", "%1"],
"file_extensions": [".csv"]
}
}
}
]
}
```

### URL protocols

Each operating system has a slightly different way of associating a URL protocol to a given shortcut.

* On Linux, you must use the `MimeType` option too.
Use the `x-scheme-handler/your-protocol-here` syntax.
Remember to add the `%u` (single URL) or `%U` (several URLs) placeholders to your command
so the URLs are passed adequately.
* On macOS, use `CFBundleURLTypes`. Requires no placeholder. The URL will be dispatched via events. Right now, the .app launcher/forwarder doesn't know about the Apple events involved in this, so **it will not work**. WIP.
* On Windows, use `url_protocols`. Remember to add the `%1` or `%*` placeholders to your command
so the URLs are passed adequately.


```json
{
"$schema": "https://json-schema.org/draft-07/schema",
"$id": "https://schemas.conda.io/menuinst-1.schema.json",
"menu_name": "Protocol handler example",
"menu_items": [
{
"name": "My custom menuinst:// handler",
"activate": true,
"command": ["{{ PREFIX }}/bin/my_protocol_handler.py"],
"icon": "{{ MENU_DIR }}/my_protocol_handler.{{ ICON_EXT }}",
"platforms": {
"linux": {
"command": ["{{ PREFIX }}/bin/my_protocol_handler.py", "%u"],
"MimeType": ["x-scheme-handler/menuinst"]
},
"macos": {
"CFBundleURLTypes": [
{
"CFBundleURLIconFile": "{{ MENU_DIR }}/my_protocol_handler",
"CFBundleURLName": "my-protocol-handler.menuinst.does-not-work-yet",
"CFBundleTypeRole": "Viewer",
"CFBundleURLSchemes": ["menuinst"]
}
]
},
"windows": {
"command": ["{{ SCRIPTS_DIR }}/my_protocol_handler.py", "%1"],
"url_protocols": ["menuinst"]
}
}
}
]
}
```
12 changes: 11 additions & 1 deletion menuinst/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ 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."
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
"File extensions that will be associated with this program."


class Linux(BasePlatformSpecific):
Expand Down Expand Up @@ -103,7 +107,13 @@ class Linux(BasePlatformSpecific):
Keywords: Optional[Union[List[str], constr(regex=r"^.+;$")]] = None
"Additional terms to describe this shortcut to aid in searching."
MimeType: Optional[Union[List[str], constr(regex=r"^.+;$")]] = None
"The MIME type(s) supported by this application."
"""
The MIME type(s) supported by this application.
Note this includes file types and URL protocols.
For URL protocols, use ``x-scheme-handler/your-protocol-here``.
For example, if you want to register ``menuinst:``, you would
include ``x-scheme-handler/menuinst``.
"""
NoDisplay: Optional[bool] = None
"""
Do not show this item in the menu. Useful to associate MIME types
Expand Down
4 changes: 3 additions & 1 deletion menuinst/data/menuinst.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
},
"win": {
"desktop": true,
"quicklaunch": true
"quicklaunch": true,
"url_protocols": null,
"file_extensions": null
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions menuinst/data/menuinst.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,22 @@
"title": "Quicklaunch",
"default": true,
"type": "boolean"
},
"url_protocols": {
"title": "Url Protocols",
"type": "array",
"items": {
"type": "string",
"pattern": "\\S+"
}
},
"file_extensions": {
"title": "File Extensions",
"type": "array",
"items": {
"type": "string",
"pattern": "\\.\\S*"
}
}
},
"additionalProperties": false
Expand Down
167 changes: 147 additions & 20 deletions menuinst/platforms/win.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
"""
"""
import os
import warnings
import shutil
import warnings
from logging import getLogger
from pathlib import Path
from subprocess import run
from subprocess import run, CompletedProcess
from tempfile import NamedTemporaryFile
from typing import Tuple, Optional, Dict, Any

from .base import Menu, MenuItem
from ..utils import WinLex, unlink

from .win_utils.knownfolders import folder_path as windows_folder_path
from .win_utils.registry import (
register_file_extension,
register_url_protocol,
unregister_file_extension,
unregister_url_protocol,
)

log = getLogger(__name__)

Expand Down Expand Up @@ -127,30 +133,13 @@ def create(self) -> Tuple[Path, ...]:
from .win_utils.winshortcut import create_shortcut

self._precreate()

activate = self.metadata["activate"]
if activate:
script = self._write_script()
paths = self._paths()

for path in paths:
if not path.suffix == ".lnk":
continue

if activate:
if self.metadata["terminal"]:
command = ["cmd", "/K", str(script)]
else:
system32 = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "system32"
command = [
str(system32 / "WindowsPowerShell" / "v1.0" / "powershell.exe"),
f"\"start '{script}' -WindowStyle hidden\"",
]
else:
command = self.render_key("command")

target_path, *arguments = WinLex.quote_args(command)

target_path, *arguments = self._process_command()
working_dir = self.render_key("working_dir")
if working_dir:
Path(working_dir).mkdir(parents=True, exist_ok=True)
Expand All @@ -171,13 +160,21 @@ def create(self) -> Tuple[Path, ...]:
working_dir,
icon,
)

self._register_file_extensions()
self._register_url_protocols()

return paths

def remove(self) -> Tuple[Path, ...]:
self._unregister_file_extensions()
self._unregister_url_protocols()

paths = self._paths()
for path in paths:
log.debug("Removing %s", path)
unlink(path, missing_ok=True)

return paths

def _paths(self) -> Tuple[Path, ...]:
Expand Down Expand Up @@ -259,3 +256,133 @@ def _write_script(self, script_path: Optional[os.PathLike] = None) -> Path:
f.write(self._command())

return script_path

def _process_command(self) -> Tuple[str]:
if self.metadata["activate"]:
script = self._write_script()
if self.metadata["terminal"]:
command = ["cmd", "/K", str(script)]
else:
system32 = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "system32"
command = [
str(system32 / "WindowsPowerShell" / "v1.0" / "powershell.exe"),
f"\"start '{script}' -WindowStyle hidden\"",
]
else:
command = self.render_key("command")

return WinLex.quote_args(command)

def _ftype_identifier(self, extension):
identifier = self.render_key("name", slug=True)
return f"{identifier}.AssocFile{extension}"

def _register_file_extensions_cmd(self):
"""
This function uses CMD's `assoc` and `ftype` commands.
"""
extensions = self.metadata["file_extensions"]
if not extensions:
return
command = " ".join(self._process_command())
exts = list(dict.fromkeys([ext.lower() for ext in extensions]))
for ext in exts:
identifier = self._ftype_identifier(ext)
self._cmd_ftype(identifier, command)
self._cmd_assoc(ext, associate_to=identifier)

def _unregister_file_extensions_cmd(self):
"""
This function uses CMD's `assoc` and `ftype` commands.
"""
extensions = self.metadata["file_extensions"]
if not extensions:
return
exts = list(dict.fromkeys([ext.lower() for ext in extensions]))
for ext in exts:
identifier = self._ftype_identifier(ext)
self._cmd_ftype(identifier) # remove
# TODO: Do we need to clean up the `assoc` mappings too?

@staticmethod
def _cmd_assoc(extension, associate_to=None, query=False, remove=False) -> CompletedProcess:
"https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/assoc"
if sum([associate_to, query, remove]) != 1:
raise ValueError("Only one of {associate_to, query, remove} must be set.")
if not extension.startswith("."):
raise ValueError("extension must startwith '.'")
if associate_to:
arg = f"{extension}={associate_to}"
elif query:
arg = extension
elif remove:
arg = f"{extension}="
p = run(
["cmd", "/C", f"assoc {arg}"],
capture_output=True,
text=True,
)
p.check_returncode()
return p

@staticmethod
def _cmd_ftype(identifier, command=None, query=False, remove=False) -> CompletedProcess:
"https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/ftype"
if sum([command, query, remove]) != 1:
raise ValueError("Only one of {command, query, remove} must be set.")
if command:
arg = f"{identifier}={command}"
elif query:
arg = identifier
elif remove:
arg = f"{identifier}="
p = run(
["cmd", "/C", f"assoc {arg}"],
capture_output=True,
text=True,
)
p.check_returncode()
return p

def _register_file_extensions(self):
"""WIP"""
extensions = self.metadata["file_extensions"]
if not extensions:
return

command = " ".join(self._process_command())
icon = self.render_key("icon")
exts = list(dict.fromkeys([ext.lower() for ext in extensions]))
for ext in exts:
identifier = self._ftype_identifier(ext)
register_file_extension(ext, identifier, command, icon=icon, mode=self.parent.mode)

def _unregister_file_extensions(self):
extensions = self.metadata["file_extensions"]
if not extensions:
return

exts = list(dict.fromkeys([ext.lower() for ext in extensions]))
for ext in exts:
identifier = self._ftype_identifier(ext)
unregister_file_extension(ext, identifier, mode=self.parent.mode)

def _register_url_protocols(self):
"See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85)"
protocols = self.metadata["url_protocols"]
if not protocols:
return
command = " ".join(self._process_command())
icon = self.render_key("icon")
for protocol in protocols:
identifier = self._ftype_identifier(protocol)
register_url_protocol(protocol, command, identifier, icon=icon, mode=self.parent.mode)

def _unregister_url_protocols(self):
protocols = self.metadata["url_protocols"]
if not protocols:
return
for protocol in protocols:
identifier = self._ftype_identifier(protocol)
unregister_url_protocol(protocol, identifier, mode=self.parent.mode)

Loading

0 comments on commit 0684438

Please sign in to comment.