Skip to content

Commit 43c93cc

Browse files
committed
download favicons for menus that support icons
wip vendor/rewrite favicon dep update PKGBUILD
1 parent f2739f4 commit 43c93cc

File tree

14 files changed

+394
-11
lines changed

14 files changed

+394
-11
lines changed

TODO

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
favicon request content type: ["image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"]

contrib/PKGBUILD

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ url="https://github.com/pvsr/qbpm"
88
license=('GPL-3.0-or-later')
99
sha512sums=('SKIP')
1010
arch=('any')
11-
depends=('python' 'python-click' 'python-xdg-base-dirs')
11+
depends=('python' 'python-click' 'python-xdg-base-dirs' 'python-httpx' 'python-pillow')
1212
makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc')
1313
provides=('qbpm')
1414
source=("git+https://github.com/pvsr/qbpm")

flake.nix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,14 @@
6060
packages = [
6161
pkgs.ruff
6262
pkgs.nixfmt-rfc-style
63+
pkgs.xdg-utils
6364
(pyprojectEnv (
6465
ps: with ps; [
6566
flit
6667
pytest
6768
mypy
6869
pylsp-mypy
70+
# types-pillow
6971
]
7072
))
7173
];

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ classifiers = [
1616
"Typing :: Typed",
1717
]
1818
requires-python = ">= 3.11"
19-
dependencies = ["click", "xdg-base-dirs"]
19+
dependencies = ["click", "xdg-base-dirs", "httpx", "pillow"]
2020

2121
[project.urls]
2222
homepage = "https://github.com/pvsr/qbpm"

qbpm.1.scd

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ appropriate \--basedir, or more conveniently using the qbpm launch and qbpm choo
8080
qbpm launch -n qb-dev --debug --json-logging
8181
```
8282

83+
*icon* [options] <profile> <icon>
84+
Install an icon to _profile_. _icon_ may be a url, a path to an image file,
85+
or, if --by-name is passed, the name of an xdg icon installed on your
86+
system. If _icon_ is a url, qbpm will fetch the page and attempt to find a
87+
suitable favicon.
88+
89+
Options:
90+
91+
*-n, --by-name*
92+
Interpret _icon_ as the name of an xdg icon file according to the
93+
freedesktop.org icon specification. Likely to only work on Linux.
94+
8395
*choose* [options]
8496
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
8597
to dmenu or another compatible menu program such as rofi, and on macOS this
@@ -131,3 +143,9 @@ Peter Rice
131143
_https://github.com/pvsr/qbpm_
132144

133145
_https://codeberg.org/pvsr/qbpm_
146+
147+
# LICENSE
148+
149+
src/qbpm/favicon.py: MIT
150+
151+
all other code and qbpm as a whole: GPLv3+

src/qbpm/choose.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99

1010
def choose_profile(
11-
profile_dir: Path, menu: str | None, foreground: bool, qb_args: tuple[str, ...]
11+
profile_dir: Path,
12+
menu: str | None,
13+
foreground: bool,
14+
qb_args: tuple[str, ...],
15+
force_icon: bool,
1216
) -> bool:
1317
dmenu = find_menu(menu)
1418
if not dmenu:
@@ -19,11 +23,13 @@ def choose_profile(
1923
error("no profiles")
2024
return False
2125
profiles = [*real_profiles, "qutebrowser"]
26+
use_icon = force_icon
27+
# use_icon = dmenu.icon_support or force_icon
2228
command = dmenu.command(sorted(profiles), "qutebrowser", " ".join(qb_args))
2329
selection_cmd = subprocess.run(
2430
command,
2531
text=True,
26-
input="\n".join(sorted(profiles)),
32+
input=build_menu_items(profiles, use_icon),
2733
stdout=subprocess.PIPE,
2834
stderr=None,
2935
check=False,
@@ -39,3 +45,20 @@ def choose_profile(
3945
else:
4046
error("no profile selected")
4147
return False
48+
49+
50+
def build_menu_items(profiles: list[str], icon: bool) -> str:
51+
# TODO build profile before passing to icons
52+
if icon and any(profile_icons := [icons.icon_for_profile(p) for p in profiles]):
53+
menu_items = [
54+
icon_entry(profile, icon)
55+
for (profile, icon) in zip(profiles, profile_icons)
56+
]
57+
else:
58+
menu_items = profiles
59+
60+
return "\n".join(sorted(menu_items))
61+
62+
63+
def icon_entry(name: str, icon: str | None) -> str:
64+
return f"{name}\0icon\x1f{icon or config.default_icon}"

src/qbpm/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# TODO real config file
2+
default_icon = "qutebrowser"
3+
application_name_suffix = " (qutebrowser profile)"

src/qbpm/desktop.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@
2020

2121

2222
# TODO expose application_dir through config
23-
def create_desktop_file(profile: Profile, application_dir: Path | None = None) -> None:
23+
def create_desktop_file(
24+
profile: Profile,
25+
application_dir: Path | None = None,
26+
icon: str | None = None,
27+
) -> None:
2428
text = textwrap.dedent(f"""\
2529
[Desktop Entry]
2630
Name={profile.name} (qutebrowser profile)
2731
StartupWMClass=qutebrowser
2832
GenericName={profile.name}
29-
Icon=qutebrowser
33+
Icon={icon or "qutebrowser"}
3034
Type=Application
3135
Categories=Network;WebBrowser;
3236
Exec={" ".join([*profile.cmdline(), "--untrusted-args", "%u"])}
@@ -46,3 +50,13 @@ def create_desktop_file(profile: Profile, application_dir: Path | None = None) -
4650
""")
4751
application_dir = application_dir or default_qbpm_application_dir()
4852
(application_dir / f"{profile.name}.desktop").write_text(text)
53+
54+
55+
# TODO
56+
# def add_to_desktop_file(profile: Profile, key: str, value: str) -> None:
57+
# desktop_file = application_dir / f"{profile.name}.desktop"
58+
# if not desktop_file.exists():
59+
# return
60+
# desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop"))
61+
# desktop.set(key, value)
62+
# desktop.write()

src/qbpm/favicon.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
SPDX-License-Identifier: MIT
3+
derived from favicon.py by Scott Werner
4+
https://github.com/scottwernervt/favicon/tree/123e431f53b2c4903b540246a85db0b1633d4786
5+
"""
6+
7+
import re
8+
from collections import defaultdict, namedtuple
9+
from html.parser import HTMLParser
10+
from pathlib import Path
11+
from typing import Any
12+
13+
import httpx
14+
15+
LINK_RELS = [
16+
"icon",
17+
"shortcut icon",
18+
]
19+
20+
SIZE_RE = re.compile(r"(?P<width>\d{2,4})x(?P<height>\d{2,4})", flags=re.IGNORECASE)
21+
22+
Icon = namedtuple("Icon", ["url", "width", "height", "format", "src"])
23+
24+
25+
def get(client: httpx.Client) -> list[Icon]:
26+
response = client.get("")
27+
response.raise_for_status()
28+
client.base_url = response.url
29+
30+
icons = {icon.url: icon for icon in tags(response.text)}
31+
32+
fallback_icon = fallback(client)
33+
if fallback_icon and fallback_icon.src not in icons:
34+
icons[fallback_icon.url] = fallback_icon
35+
36+
# print(f"{icons=}")
37+
return list(icons.values())
38+
# return sorted(icons, key=lambda i: i.width + i.height, reverse=True)
39+
40+
41+
def fallback(client: httpx.Client) -> Icon | None:
42+
response = client.head("favicon.ico")
43+
if response.status_code == 200 and response.headers["Content-Type"].startswith(
44+
"image"
45+
):
46+
return Icon(response.url, 0, 0, ".ico", "default")
47+
return None
48+
49+
50+
class LinkRelParser(HTMLParser):
51+
def __init__(self) -> None:
52+
super().__init__()
53+
self.icons: dict[str, set[str]] = defaultdict(set)
54+
55+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
56+
if tag == "link":
57+
data = dict(attrs)
58+
rel = data.get("rel")
59+
if rel in LINK_RELS and (href := data.get("href") or data.get("content")):
60+
# TODO replace with data
61+
self.icons[rel].add(href)
62+
63+
64+
def tags(html: str) -> set[Icon]:
65+
parser = LinkRelParser()
66+
parser.feed(html[0 : html.find("</head>")])
67+
hrefs = {link.strip() for links in parser.icons.values() for link in links}
68+
69+
icons = set()
70+
for href in hrefs:
71+
if not href or href.startswith("data:image/"):
72+
continue
73+
74+
# url_parsed = urlparse(url)
75+
# repair '//cdn.network.com/favicon.png' or `icon.png?v2`
76+
href_parsed = httpx.URL(href)
77+
78+
width, height = (0, 0) # dimensions(tag)
79+
ext = Path(href_parsed.path).suffix
80+
81+
icon = Icon(
82+
href_parsed,
83+
width,
84+
height,
85+
ext.lower(),
86+
"TODO",
87+
)
88+
icons.add(icon)
89+
90+
return icons
91+
92+
93+
def dimensions(tag: Any) -> tuple[int, int]:
94+
"""Get icon dimensions from size attribute or icon filename.
95+
96+
:param tag: Link or meta tag.
97+
:type tag: :class:`bs4.element.Tag`
98+
99+
:return: If found, width and height, else (0,0).
100+
:rtype: tuple(int, int)
101+
"""
102+
sizes = tag.get("sizes", "")
103+
if sizes and sizes != "any":
104+
size = sizes.split(" ") # '16x16 32x32 64x64'
105+
size.sort(reverse=True)
106+
width, height = re.split(r"[x\xd7]", size[0])
107+
else:
108+
filename = tag.get("href") or tag.get("content")
109+
size = SIZE_RE.search(filename)
110+
if size:
111+
width, height = size.group("width"), size.group("height")
112+
else:
113+
width, height = "0", "0"
114+
115+
# repair bad html attribute values: sizes='192x192+'
116+
width = "".join(c for c in width if c.isdigit())
117+
height = "".join(c for c in height if c.isdigit())
118+
return int(width), int(height)

0 commit comments

Comments
 (0)