diff --git a/TODO b/TODO new file mode 100644 index 0000000..272033d --- /dev/null +++ b/TODO @@ -0,0 +1 @@ +favicon request content type: ["image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"] diff --git a/contrib/PKGBUILD b/contrib/PKGBUILD index 352320a..fc8e2cf 100644 --- a/contrib/PKGBUILD +++ b/contrib/PKGBUILD @@ -8,7 +8,7 @@ url="https://github.com/pvsr/qbpm" license=('GPL-3.0-or-later') sha512sums=('SKIP') arch=('any') -depends=('python' 'python-click' 'python-xdg-base-dirs') +depends=('python' 'python-click' 'python-xdg-base-dirs' 'python-httpx' 'python-pillow') makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc') provides=('qbpm') source=("git+https://github.com/pvsr/qbpm") diff --git a/flake.nix b/flake.nix index 1d3dd5b..7fe6bac 100644 --- a/flake.nix +++ b/flake.nix @@ -60,12 +60,14 @@ packages = [ pkgs.ruff pkgs.nixfmt-rfc-style + pkgs.xdg-utils (pyprojectEnv ( ps: with ps; [ flit pytest mypy pylsp-mypy + # types-pillow ] )) ]; diff --git a/pyproject.toml b/pyproject.toml index 3c02072..4f2cbc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">= 3.11" -dependencies = ["click", "xdg-base-dirs"] +dependencies = ["click", "xdg-base-dirs", "httpx", "pillow"] [project.urls] homepage = "https://github.com/pvsr/qbpm" diff --git a/qbpm.1.scd b/qbpm.1.scd index e0cd965..1086834 100644 --- a/qbpm.1.scd +++ b/qbpm.1.scd @@ -80,6 +80,18 @@ appropriate \--basedir, or more conveniently using the qbpm launch and qbpm choo qbpm launch -n qb-dev --debug --json-logging ``` +*icon* [options] + Install an icon to _profile_. _icon_ may be a url, a path to an image file, + or, if --by-name is passed, the name of an xdg icon installed on your + system. If _icon_ is a url, qbpm will fetch the page and attempt to find a + suitable favicon. + + Options: + + *-n, --by-name* + Interpret _icon_ as the name of an xdg icon file according to the + freedesktop.org icon specification. Likely to only work on Linux. + *choose* [options] Open a menu to choose a qutebrowser profile to launch. On linux this defaults to dmenu or another compatible menu program such as rofi, and on macOS this @@ -131,3 +143,9 @@ Peter Rice _https://github.com/pvsr/qbpm_ _https://codeberg.org/pvsr/qbpm_ + +# LICENSE + +src/qbpm/favicon.py: MIT + +all other code and qbpm as a whole: GPLv3+ diff --git a/src/qbpm/choose.py b/src/qbpm/choose.py index 1f0938a..7569c1e 100644 --- a/src/qbpm/choose.py +++ b/src/qbpm/choose.py @@ -8,7 +8,11 @@ def choose_profile( - profile_dir: Path, menu: str | None, foreground: bool, qb_args: tuple[str, ...] + profile_dir: Path, + menu: str | None, + foreground: bool, + qb_args: tuple[str, ...], + force_icon: bool, ) -> bool: dmenu = find_menu(menu) if not dmenu: @@ -19,11 +23,13 @@ def choose_profile( error("no profiles") return False profiles = [*real_profiles, "qutebrowser"] + use_icon = force_icon + # use_icon = dmenu.icon_support or force_icon command = dmenu.command(sorted(profiles), "qutebrowser", " ".join(qb_args)) selection_cmd = subprocess.run( command, text=True, - input="\n".join(sorted(profiles)), + input=build_menu_items(profiles, use_icon), stdout=subprocess.PIPE, stderr=None, check=False, @@ -39,3 +45,20 @@ def choose_profile( else: error("no profile selected") return False + + +def build_menu_items(profiles: list[str], icon: bool) -> str: + # TODO build profile before passing to icons + if icon and any(profile_icons := [icons.icon_for_profile(p) for p in profiles]): + menu_items = [ + icon_entry(profile, icon) + for (profile, icon) in zip(profiles, profile_icons) + ] + else: + menu_items = profiles + + return "\n".join(sorted(menu_items)) + + +def icon_entry(name: str, icon: str | None) -> str: + return f"{name}\0icon\x1f{icon or config.default_icon}" diff --git a/src/qbpm/config.py b/src/qbpm/config.py new file mode 100644 index 0000000..4c44084 --- /dev/null +++ b/src/qbpm/config.py @@ -0,0 +1,3 @@ +# TODO real config file +default_icon = "qutebrowser" +application_name_suffix = " (qutebrowser profile)" diff --git a/src/qbpm/desktop.py b/src/qbpm/desktop.py index 529ac76..16e22de 100644 --- a/src/qbpm/desktop.py +++ b/src/qbpm/desktop.py @@ -20,13 +20,17 @@ # TODO expose application_dir through config -def create_desktop_file(profile: Profile, application_dir: Path | None = None) -> None: +def create_desktop_file( + profile: Profile, + application_dir: Path | None = None, + icon: str | None = None, +) -> None: text = textwrap.dedent(f"""\ [Desktop Entry] Name={profile.name} (qutebrowser profile) StartupWMClass=qutebrowser GenericName={profile.name} - Icon=qutebrowser + Icon={icon or "qutebrowser"} Type=Application Categories=Network;WebBrowser; Exec={" ".join([*profile.cmdline(), "--untrusted-args", "%u"])} @@ -46,3 +50,13 @@ def create_desktop_file(profile: Profile, application_dir: Path | None = None) - """) application_dir = application_dir or default_qbpm_application_dir() (application_dir / f"{profile.name}.desktop").write_text(text) + + +# TODO +# def add_to_desktop_file(profile: Profile, key: str, value: str) -> None: +# desktop_file = application_dir / f"{profile.name}.desktop" +# if not desktop_file.exists(): +# return +# desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop")) +# desktop.set(key, value) +# desktop.write() diff --git a/src/qbpm/favicon.py b/src/qbpm/favicon.py new file mode 100644 index 0000000..2427065 --- /dev/null +++ b/src/qbpm/favicon.py @@ -0,0 +1,118 @@ +""" +SPDX-License-Identifier: MIT +derived from favicon.py by Scott Werner +https://github.com/scottwernervt/favicon/tree/123e431f53b2c4903b540246a85db0b1633d4786 +""" + +import re +from collections import defaultdict, namedtuple +from html.parser import HTMLParser +from pathlib import Path +from typing import Any + +import httpx + +LINK_RELS = [ + "icon", + "shortcut icon", +] + +SIZE_RE = re.compile(r"(?P\d{2,4})x(?P\d{2,4})", flags=re.IGNORECASE) + +Icon = namedtuple("Icon", ["url", "width", "height", "format", "src"]) + + +def get(client: httpx.Client) -> list[Icon]: + response = client.get("") + response.raise_for_status() + client.base_url = response.url + + icons = {icon.url: icon for icon in tags(response.text)} + + fallback_icon = fallback(client) + if fallback_icon and fallback_icon.src not in icons: + icons[fallback_icon.url] = fallback_icon + + # print(f"{icons=}") + return list(icons.values()) + # return sorted(icons, key=lambda i: i.width + i.height, reverse=True) + + +def fallback(client: httpx.Client) -> Icon | None: + response = client.head("favicon.ico") + if response.status_code == 200 and response.headers["Content-Type"].startswith( + "image" + ): + return Icon(response.url, 0, 0, ".ico", "default") + return None + + +class LinkRelParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.icons: dict[str, set[str]] = defaultdict(set) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "link": + data = dict(attrs) + rel = data.get("rel") + if rel in LINK_RELS and (href := data.get("href") or data.get("content")): + # TODO replace with data + self.icons[rel].add(href) + + +def tags(html: str) -> set[Icon]: + parser = LinkRelParser() + parser.feed(html[0 : html.find("")]) + hrefs = {link.strip() for links in parser.icons.values() for link in links} + + icons = set() + for href in hrefs: + if not href or href.startswith("data:image/"): + continue + + # url_parsed = urlparse(url) + # repair '//cdn.network.com/favicon.png' or `icon.png?v2` + href_parsed = httpx.URL(href) + + width, height = (0, 0) # dimensions(tag) + ext = Path(href_parsed.path).suffix + + icon = Icon( + href_parsed, + width, + height, + ext.lower(), + "TODO", + ) + icons.add(icon) + + return icons + + +def dimensions(tag: Any) -> tuple[int, int]: + """Get icon dimensions from size attribute or icon filename. + + :param tag: Link or meta tag. + :type tag: :class:`bs4.element.Tag` + + :return: If found, width and height, else (0,0). + :rtype: tuple(int, int) + """ + sizes = tag.get("sizes", "") + if sizes and sizes != "any": + size = sizes.split(" ") # '16x16 32x32 64x64' + size.sort(reverse=True) + width, height = re.split(r"[x\xd7]", size[0]) + else: + filename = tag.get("href") or tag.get("content") + size = SIZE_RE.search(filename) + if size: + width, height = size.group("width"), size.group("height") + else: + width, height = "0", "0" + + # repair bad html attribute values: sizes='192x192+' + width = "".join(c for c in width if c.isdigit()) + height = "".join(c for c in height if c.isdigit()) + return int(width), int(height) diff --git a/src/qbpm/icons.py b/src/qbpm/icons.py new file mode 100644 index 0000000..b4186d1 --- /dev/null +++ b/src/qbpm/icons.py @@ -0,0 +1,143 @@ +import re +import shutil +from collections.abc import Callable, Iterator +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional + +import httpx +from PIL import Image + +from . import Profile, __version__, favicon +from .favicon import Icon +from .log import error, info + +# TODO more preferences: 16x16, 32, 0, other +PREFERRED_ICONS = [ + re.compile(p) + for p in [ + r"favicon.*\.svg$", + r"favicon.*\.ico$", + r"favicon.*\.png$", + r"\.ico$", + r"icon\.png$", + r"\.svg$", + ] +] + + +def choose_icon(icons: list[Icon]) -> Iterator[Icon]: + preferred = PREFERRED_ICONS + for pattern in preferred: + for icon in icons: + if pattern.search(icon.url.path): + info(f"chose {icon.url}") + yield icon + icons.remove(icon) + yield from choose_icon(icons) + + +headers = {"user-agent": f"qbpm/{__version__}"} + + +def download_icon(profile: Profile, home_page: str, overwrite: bool) -> Optional[Path]: + base_url = httpx.URL(home_page) + if not base_url.scheme: + base_url = httpx.URL("https://" + home_page) + client = httpx.Client(base_url=base_url, headers=headers, follow_redirects=True) + tmp_dir = TemporaryDirectory() + work_dir = Path(tmp_dir.name) + try: + for icon in choose_icon(favicon.get(client)): + icon_body = client.get(icon.url) + if icon_body.status_code != 200: + info(f"got bad response code {icon_body.status_code} for {icon.url}") + return None + elif not icon_body.headers["Content-Type"].startswith("image"): + info(f"{icon.url} is not an image") + return None + icon_body.raise_for_status() + work_icon = work_dir / f"favicon{icon.format}" + with work_icon.open("wb") as icon_file: + for chunk in icon_body.iter_bytes(1024): + icon_file.write(chunk) + icon_path = install_icon_file(profile, work_icon, overwrite, icon.url) + if icon_path: + # print(f"installed {client.base_url.join(icon.url)}") + return icon_path + # TODO pretty print + info(f"no favicons found matching one of {PREFERRED_ICONS}") + return None + except Exception as e: + # info(str(e)) + raise e + error(f"failed to fetch favicon from {home_page}") + return None + finally: + tmp_dir.cleanup() + client.close() + + +def icon_for_profile(profile: Profile) -> Optional[str]: + icon_file = next(find_icon_files(profile), None) + if icon_file and icon_file.suffix == ".name": + return icon_file.read_text() + return str(icon_file) if icon_file else None + + +def install_icon_file( + profile: Profile, src: Path, overwrite: bool, origin: Optional[str] = None +) -> Optional[Path]: + icon_format = src.suffix + dest = (profile.root / f"icon{icon_format}").absolute() + clean_icons = check_for_icons(profile, overwrite, dest) + if clean_icons is None: + return None + if icon_format not in {".png", ".svg"}: + dest = dest.with_suffix(".png") + try: + image = Image.open(src) + image.save(dest, format="png") + except Exception as e: + error(str(e)) + error(f"failed to convert {origin or src} to png") + dest.unlink(missing_ok=True) + return None + elif src.resolve() != dest: + shutil.copy(src, dest) + clean_icons() + print(dest) + return dest + + +def install_icon_by_name(profile: Profile, icon_name: str, overwrite: bool) -> bool: + clean_icons = check_for_icons(profile, overwrite) + if clean_icons is None: + return False + clean_icons() + file = profile.root / "icon.name" + file.write_text(icon_name) + return True + + +def check_for_icons( + profile: Profile, overwrite: bool, dest: Path | None = None +) -> Callable[[], None] | None: + existing_icons = set(find_icon_files(profile)) + if existing_icons and not overwrite: + error(f"icon already exists in {profile.root}, pass --overwrite to replace it") + return None + + def clean_icons() -> None: + keep = {dest} if dest else set() + for icon in existing_icons - keep: + icon.unlink() + + return clean_icons + + +def find_icon_files(profile: Profile) -> Iterator[Path]: + for ext in ["png", "svg", "name"]: + icon = profile.root / f"icon.{ext}" + if icon.is_file(): + yield icon.absolute() diff --git a/src/qbpm/log.py b/src/qbpm/log.py index 23d0a02..7470444 100644 --- a/src/qbpm/log.py +++ b/src/qbpm/log.py @@ -1,6 +1,10 @@ import logging +def debug(msg: str) -> None: + logging.debug(msg) + + def info(msg: str) -> None: logging.info(msg) diff --git a/src/qbpm/main.py b/src/qbpm/main.py index 6d090ba..c16e854 100644 --- a/src/qbpm/main.py +++ b/src/qbpm/main.py @@ -56,6 +56,7 @@ def command( for opt in reversed( [ + # TODO --icon/--no-icon click.option( "-C", "--qutebrowser-config-dir", @@ -207,16 +208,29 @@ def launch_profile( @click.option( "-f", "--foreground", is_flag=True, help="Run qutebrowser in the foreground." ) +@click.option( + "-i", + "--icon", + "force_icon", + is_flag=True, + help="Attach icons to menu items using rofi's extended dmenu spec even if your menu is not known to support it. Only works if at least one profile has an icon installed.", +) @click.pass_obj def choose( - context: Context, menu: str | None, foreground: bool, qb_args: tuple[str, ...] + context: Context, + menu: str | None, + foreground: bool, + qb_args: tuple[str, ...], + force_icon: bool, ) -> None: """Choose a profile to launch. Support is built in for many X and Wayland launchers, as well as applescript dialogs. All QB_ARGS are passed on to qutebrowser. """ - exit_with(choose_profile(context.profile_dir, menu, foreground, qb_args)) + exit_with( + choose_profile(context.profile_dir, menu, foreground, qb_args, force_icon) + ) @main.command() @@ -250,6 +264,25 @@ def desktop( exit_with(operations.desktop(profile)) +@main.command() +@click.argument("profile_name") +@click.argument("icon", metavar="ICON_LOCATION") +@click.option( + "-n", + "--by-name", + is_flag=True, + help="interpret ICON_LOCATION as the name of an icon in an installed icon theme instead of a file or url.", +) +@click.option("--overwrite", is_flag=True, help="Replace the current icon.") +@click.pass_obj +def icon(context: Context, profile_name: str, **kwargs: Any) -> None: + """Install an icon for the profile. ICON_LOCATION may be a url to download a favicon from, + a path to an image file, or, with --by-name, the name of an xdg icon installed on your system. + """ + profile = Profile(profile_name, **vars(context)) + exit_with(operations.icon(profile, **kwargs)) + + def session_info( session: str, profile_name: str | None, context: Context ) -> tuple[Profile, Path]: diff --git a/src/qbpm/operations.py b/src/qbpm/operations.py index f5f90d6..57f6aa0 100644 --- a/src/qbpm/operations.py +++ b/src/qbpm/operations.py @@ -1,7 +1,7 @@ import shutil from pathlib import Path -from . import Profile, profiles +from . import Profile, icons, profiles from .desktop import create_desktop_file @@ -25,5 +25,21 @@ def from_session( def desktop(profile: Profile) -> bool: exists = profiles.check(profile) if exists: - create_desktop_file(profile) + create_desktop_file(profile, icon=icons.icon_for_profile(profile)) return exists + + +def icon(profile: Profile, icon: str, by_name: bool, overwrite: bool) -> bool: + if not profiles.check(profile): + return False + if by_name: + icon_id = icon if icons.install_icon_by_name(profile, icon, overwrite) else None + else: + if Path(icon).is_file(): + icon_file = icons.install_icon_file(profile, Path(icon), overwrite) + else: + icon_file = icons.download_icon(profile, icon, overwrite) + icon_id = str(icon_file) if icon_file else None + if icon_id: + profiles.add_to_desktop_file(profile, "Icon", icon_id) + return icon_id is not None diff --git a/src/qbpm/profiles.py b/src/qbpm/profiles.py index 3e19e15..a78b351 100644 --- a/src/qbpm/profiles.py +++ b/src/qbpm/profiles.py @@ -81,8 +81,12 @@ def new_profile( return False if create_profile(profile, overwrite): create_config(profile, qb_config_dir, home_page, overwrite) + icon = None + if home_page: + # TODO error handling + icon = icons.download_icon(profile, home_page, overwrite) if desktop_file is True or (desktop_file is not False and platform == "linux"): - create_desktop_file(profile) + create_desktop_file(profile, icon=str(icon) if icon else None) return True return False