Desktop entries#
根据 Freedesktop 规范,应用程序通常会创建一个 .desktop
文件,用于在桌面环境中注册自身。系统范围安装的应用程序,其 .desktop
文件通常位于 /usr/share/applications/
或 /usr/local/share/applications/
目录;而用户特定安装的应用程序,则通常位于 ~/.local/share/applications/
。在解析时,用户目录中的条目优先于系统目录中的条目。
文件格式如下,具体参考 https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html
[Desktop Entry]
# The type as listed above
Type=Application
# The version of the desktop entry specification to which this file complies
Version=1.0
# The name of the application
Name=jMemorize
# A comment which can/will be used as a tooltip
Comment=Flash card based learning tool
# The path to the folder in which the executable is run
Path=/opt/jmemorise
# The executable of the application, possibly with arguments.
Exec=jmemorize
# The name of the icon that will be used to display this entry
Icon=jmemorize
# Describes whether this application needs to be run in a terminal or not
Terminal=false
# Describes the categories in which this entry should be shown
Categories=Education;Languages;Java;
如 Firefox 的 /usr/share/applications/firefox.desktop
[Desktop Entry]
Version=1.0
Type=Application
Exec=/usr/lib/firefox/firefox %u
Terminal=false
X-MultipleArgs=false
Icon=firefox
StartupWMClass=firefox
DBusActivatable=false
Categories=GNOME;GTK;Network;WebBrowser;
MimeType=application/json;application/pdf;application/rdf+xml;application/rss+xml;application/x-xpinstall;application/xhtml+xml;application/xml;audio/flac;audio/ogg;audio/webm;image/avif;image/gif;image/jpeg;image/png;image/svg+xml;image/webp;text/html;text/xml;video/ogg;video/webm;x-scheme-handler/chrome;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/mailto;
StartupNotify=true
Actions=new-window;new-private-window;open-profile-manager;
Name=Firefox
Name[zh_CN]=Firefox
Comment=Browse the World Wide Web
Comment[zh_CN]=浏览万维网
GenericName=Web Browser
GenericName[zh_CN]=Web 浏览器
Keywords=Internet;WWW;Browser;Web;Explorer;
Keywords[zh_CN]=Internet;WWW;Browser;Web;Explorer;
X-GNOME-FullName=Firefox Web Browser
X-GNOME-FullName[zh_CN]=Firefox 浏览器
[Desktop Action new-window]
Exec=/usr/lib/firefox/firefox --new-window %u
Name=New Window
Name[zh_CN]=新建窗口
[Desktop Action new-private-window]
Exec=/usr/lib/firefox/firefox --private-window %u
Name=New Private Window
Name[zh_CN]=新建隐私窗口
[Desktop Action open-profile-manager]
Exec=/usr/lib/firefox/firefox --ProfileManager
Name=Open Profile Manager
Name[zh_CN]=打开配置文件管理器
Desktop icon#
参考 https://specifications.freedesktop.org/icon-theme-spec/latest/。为了显示应用程序的图标,我们需要考虑
- 图标路径
- 文件的类型,
png
/svg
- 系统主题,应用程序可能会提供一组图表用于不同的主题中
- 缩放,
32x32
,64x64
不同大小,需要结合用户屏幕的dpi
Freedesktop 规范规定了程序应该按照什么顺序和在哪些目录中查找图标:
$HOME/.icons
(for backwards compatibility)$XDG_DATA_DIRS/icons
/usr/share/pixmaps
路径查找首先在当前主题中进行,然后递归地在当前主题的每个父主题中进行,最后要求 fallback 到 hicolor
主题。因此想要安装应用程序图标,使其在 KDE 和 Gnome 菜单中正常工作,至少应该在 hicolor
中存放一组不同尺寸的图表。
获取当前主题可以通过 gsettings
命令,也可以读取 DConf 数据库来获取。参考 https://superuser.com/questions/581974/how-do-i-get-current-icon-theme-name-in-linux
$ gsettings get org.gnome.desktop.interface icon-theme
'McMojave-circle-dark'
$ gsettings get org.gnome.desktop.interface gtk-theme
'Breeze'
$ dconf read /org/gnome/desktop/interface/icon-theme
'McMojave-circle-dark'
比如 Papirus
这个主题,类似 Desktop Entry,它也有一个 ini
格式的配置文件,在 /usr/share/icons/Papirus/index.theme
。
[Icon Theme]
Name=Papirus
Comment=Papirus icon theme
Inherits=breeze,hicolor
Inherits
会告诉我们当前主题所继承的主题名称。同时这个文件中会声明不同大小的图标的目录的名称
/usr/share/icons/Papirus/32x32/apps/firefox.svg
/usr/share/icons/hicolor/32x32/apps/firefox.png
/usr/share/icons/bloom-classic/apps/32/firefox.svg
Icon 路径查找实现#
这里参考 Albert launcher 中的 C++ 实现。原始代码位于 https://github.com/albertlauncher/albert/blob/4f9c32b19b60ebc55393ab2111f263dd1c6582ea/src/platform/xdg/iconlookup.cpp
等价 Python 实现
import configparser
import os
import subprocess
from pathlib import Path
from typing import List, Optional
ICON_EXTENSIONS = ["png", "svg", "xpm"]
FALLBACK_THEME = "hicolor"
class IconLookup:
def __init__(self) -> None:
self.icon_dirs = self._init_icon_dirs()
self.icon_cache = {}
def _init_icon_dirs(self) -> List[Path]:
dirs = [
Path.home() / ".icons",
*map(
lambda p: Path(p) / "icons",
(
os.environ.get(
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
).split(":")
),
),
Path("/usr/local/share/pixmaps"),
Path("/usr/share/pixmaps"),
]
return [d for d in dirs if d.exists()]
def icon_path(
self,
icon_name: str,
*,
theme_name: Optional[str] = None,
desired_size: Optional[int] = None,
) -> Optional[str]:
if not icon_name:
return None
if os.path.isabs(icon_name) and os.path.exists(icon_name):
return icon_name
for ext in ICON_EXTENSIONS:
if icon_name.endswith(f".{ext}"):
icon_name = icon_name[: -(len(ext) + 1)]
cache_key = (icon_name, desired_size)
if cache_key in self.icon_cache:
return self.icon_cache[cache_key]
checked_themes = []
theme_name = theme_name or self.get_default_theme()
# Theme lookup
path = self._do_recursive_icon_lookup(
icon_name, theme_name, checked_themes, desired_size
)
if path:
self.icon_cache[cache_key] = path
return path
# Fallback to hicolor
if FALLBACK_THEME not in checked_themes:
path = self._do_recursive_icon_lookup(
icon_name, FALLBACK_THEME, checked_themes, desired_size
)
if path:
self.icon_cache[cache_key] = path
return path
# Unsorted search
for icon_dir in self.icon_dirs:
for ext in ICON_EXTENSIONS:
candidate = icon_dir / f"{icon_name}.{ext}"
if candidate.exists():
candidate_str = str(candidate)
self.icon_cache[cache_key] = candidate_str
return candidate_str
self.icon_cache[cache_key] = None
return None
def _do_recursive_icon_lookup(
self,
icon_name: str,
theme_name: str,
checked: List[str],
desired_size: Optional[int],
) -> Optional[str]:
if theme_name in checked:
return None
checked.append(theme_name)
theme_file = self._lookup_theme_file(theme_name)
if not theme_file:
return None
path = self._do_icon_lookup(icon_name, theme_file, desired_size)
if path:
return path
parents = self._parse_theme_parents(theme_file)
for parent in parents:
path = self._do_recursive_icon_lookup(
icon_name, parent, checked, desired_size
)
if path:
return path
return None
def _lookup_theme_file(self, theme_name: str) -> Optional[Path]:
for icon_dir in self.icon_dirs:
candidate = icon_dir / theme_name / "index.theme"
if candidate.exists():
return candidate
return None
def _do_icon_lookup(
self, icon_name: str, theme_file: Path, desired_size: Optional[int]
) -> Optional[str]:
parser = configparser.ConfigParser()
parser.read(theme_file)
theme_dir = theme_file.parent
dirs = parser.get("Icon Theme", "Directories", fallback="").split(",")
dirs_and_sizes = []
for d in dirs:
d = d.strip()
size = parser.getint(d, "Size", fallback=0)
dirs_and_sizes.append((d, size))
if desired_size is not None:
dirs_and_sizes.sort(key=lambda x: abs(x[1] - desired_size))
else:
dirs_and_sizes.sort(key=lambda x: -x[1])
for subdir, _ in dirs_and_sizes:
for icon_dir in self.icon_dirs:
for ext in ICON_EXTENSIONS:
path = icon_dir / theme_dir.name / subdir / f"{icon_name}.{ext}"
if path.exists():
return str(path)
return None
def _parse_theme_parents(self, theme_file: Path) -> List[str]:
parser = configparser.ConfigParser()
parser.read(theme_file)
inherits = parser.get("Icon Theme", "Inherits", fallback="")
return [s.strip() for s in inherits.split(",") if s.strip()]
def get_default_theme(self) -> str:
try:
return (
subprocess.check_output(
[
"gsettings",
"get",
"org.gnome.desktop.interface",
"icon-theme",
]
)
.decode()
.strip("\r\n' ")
)
except subprocess.SubprocessError:
return FALLBACK_THEME
if __name__ == "__main__":
lookup = IconLookup()
for name, size in [
("firefox", 64),
("firefox-nightly", 32),
("telegram-desktop", None),
("Alacritty", 32),
("albert", 32),
("electron", 32),
]:
print(
f"{name:<24}:",
# lookup.icon_path(name, desired_size=size),
lookup.icon_path(name, theme_name="bloom", desired_size=size),
)