defmodule GitGud.Packages.Formats.Desktop do
@moduledoc """
A Linux package's name and icon, as its desktop entry gives them —
the `.deb`/`.rpm` counterpart of an APK's `android:label` and
launcher icon.
A package that installs an application ships a desktop entry,
`/usr/share/applications/<id>.desktop`, whose `Name=` is what a
launcher shows and whose `Icon=` names an image the package ships
alongside it:
[Desktop Entry]
Type=Application
Name=KoCo
Icon=koco
→ /usr/share/icons/hicolor/256x256/apps/koco.png
`Icon=` is either an absolute path or a theme name. A name is looked
up the way the icon theme spec does it, cut down to what the package
itself contains: `<name>.png` under any `share/icons/` theme or
`share/pixmaps/`, the largest that is still a sensible size. Only
PNG: an SVG would need rasterising or serving user-supplied SVG to
browsers, and the mobile client can't draw one either.
Read once at publish, by the adapters — the icon is stored the way
F-Droid's is, under `icon_key`, so `GitGud.Packages.icon/1` and the
catalog need nothing new. Packages published before that are filled
in by `backfill_icons/1`.
"""
alias GitGud.Packages
alias GitGud.Packages.Apt.Native
alias GitGud.Packages.Formats.Deb.Control
alias GitGud.Packages.Formats.Rpm.Cpio
alias GitGud.Packages.Formats.Rpm.Header
alias GitGud.Packages.Formats.Tar
alias GitGud.Packages.PackageFile
# Decompressed payloads past this are not looked inside. An icon is
# decoration, and the payload is uploader-controlled: a few kilobytes
# of xz can claim to be gigabytes.
@payload_limit 256 * 1024 * 1024
# Biggest icon worth keeping. Bigger than this is a splash screen.
@max_icon_bytes 1024 * 1024
# Preferred ceiling on edge length; anything up to it, the larger the
# better. Past it, the smallest.
@preferred_max 512
@png <<0x89, "PNG\r\n", 0x1A, "\n">>
@doc """
Decompress a package payload, giving up past `@payload_limit`.
`kind` is `:gzip`, `:xz`, `:zstd` or `:none`.
"""
@spec decompress(atom(), binary()) :: {:ok, binary()} | :error
def decompress(:none, blob), do: {:ok, blob}
def decompress(:gzip, blob), do: gunzip(blob)
def decompress(:xz, blob), do: nif(Native.xz_decompress_capped(blob, @payload_limit))
def decompress(:zstd, blob), do: nif(Native.zstd_decompress_capped(blob, @payload_limit))
def decompress(_kind, _blob), do: :error
defp nif({:ok, bytes}), do: {:ok, bytes}
defp nif(_), do: :error
# `:zlib.gunzip/1` has no ceiling; inflating a chunk at a time does.
defp gunzip(blob) do
z = :zlib.open()
try do
:ok = :zlib.inflateInit(z, 31)
inflate(z, :zlib.safeInflate(z, blob), [], 0)
rescue
_ -> :error
after
:zlib.close(z)
end
end
defp inflate(z, {status, out}, acc, size) do
size = size + IO.iodata_length(out)
cond do
size > @payload_limit -> :error
status == :finished -> {:ok, IO.iodata_to_binary([acc, out])}
true -> inflate(z, :zlib.safeInflate(z, []), [acc, out], size)
end
end
@doc """
What the desktop entry in a `.deb` or `.rpm` says, as metadata to
merge into its package file's: `label`, and the `icon_*` keys with
the icon stored. Empty when the package carries no desktop entry, or
nothing usable — an icon is decoration, and nothing here fails a
publish.
`package` is the package name, which picks between several entries.
"""
@spec metadata(String.t(), binary(), String.t()) :: map()
def metadata(format, body, package) do
case read(format, body, package) do
{:ok, found} -> stored(found)
:none -> %{}
end
end
defp stored(%{label: label, icon: icon}) do
label = if label, do: %{"label" => label}, else: %{}
Map.merge(label, store(icon))
end
@doc """
The chosen desktop entry's `Name=` and icon bytes in a `.deb` or
`.rpm`, without storing anything — or `:none`.
"""
@spec read(String.t(), binary(), String.t()) ::
{:ok, %{label: String.t() | nil, icon: binary() | nil}} | :none
def read(format, body, package) do
case entries(format, body) do
{:ok, entries} -> find(entries, package)
_ -> :none
end
end
defp entries("deb", body) do
with {:ok, kind, blob} <- Control.data(body),
{:ok, tar} <- decompress(kind, blob),
do: Tar.entries(tar)
end
# The header lists every file, so an RPM with no desktop entry — most
# of them — is known not to have one without decompressing anything.
defp entries("rpm", body) do
with {:ok, header} <- Header.parse(body),
true <- Enum.any?(Header.files(header), &String.ends_with?(&1, ".desktop")),
{:ok, kind, blob} <- Header.payload(body, header),
{:ok, cpio} <- decompress(kind, blob),
do: Cpio.entries(cpio)
end
defp entries(_format, _body), do: :error
defp store(nil), do: %{}
defp store(bytes) do
case Packages.store_icon(bytes, "png") do
{:ok, meta} -> meta
# An icon is decoration; a publish doesn't fail over one.
:error -> %{}
end
end
@doc """
The chosen desktop entry's `Name=` and icon bytes, or `:none`.
"""
@spec find(map(), String.t()) :: {:ok, %{label: String.t() | nil, icon: binary() | nil}} | :none
def find(entries, package) do
entries
|> desktop_entries()
|> Enum.sort_by(&rank(&1, package))
|> case do
[] ->
:none
[first | _] = ranked ->
# The first entry that resolves to an icon, for the icon *and*
# the name — a name from one entry over another's icon would be
# a picture of something else. Failing every icon, the
# best-ranked entry still names the package.
case Enum.find_value(ranked, &with_icon(&1, entries)) do
nil -> {:ok, %{label: first.name, icon: nil}}
found -> {:ok, found}
end
end
end
defp with_icon(%{icon: nil}, _entries), do: nil
defp with_icon(entry, entries) do
case icon(entry.icon, entries) do
nil -> nil
bytes -> %{label: entry.name, icon: bytes}
end
end
# ── backfill ────────────────────────────────────────────────────────
@formats ~w(deb rpm)
# Only these come from the re-read.
@backfilled ~w(label icon_key icon_sha256 icon_size icon_ext)
@doc """
Re-read published `.deb`s and `.rpm`s and record the desktop entry's
name and icon on each, for packages published before the adapters
read them.
Options:
* `:all` — every file, not just each package's newest. The newest
is the default because it is the only one the packages pages
read.
* `:dry_run` — report, write nothing.
* `:on_file` — called with a report map per file.
Returns `{read, written}`. Idempotent: icons are stored by content,
and a package with no desktop entry is read and left alone.
"""
@spec backfill_icons(keyword()) :: {non_neg_integer(), non_neg_integer()}
def backfill_icons(opts \\ []) do
all? = Keyword.get(opts, :all, false)
dry? = Keyword.get(opts, :dry_run, false)
on_file = Keyword.get(opts, :on_file, fn _ -> :ok end)
all?
|> backfill_candidates()
|> Enum.reduce({0, 0}, fn file, {read, written} ->
case backfill_file(file, dry?) do
{:ok, report} ->
on_file.(report)
{read + 1, if(report.written?, do: written + 1, else: written)}
{:error, report} ->
on_file.(report)
{read + 1, written}
end
end)
end
defp backfill_candidates(all?) do
import Ecto.Query
files =
from(f in PackageFile,
join: v in assoc(f, :package_version),
join: p in assoc(v, :package),
where: p.format in @formats,
preload: [package_version: {v, package: p}]
)
|> GitGud.Repo.all()
if all? do
Enum.sort_by(files, & &1.id)
else
# The same "newest" `Packages.latest_files/1` picks, which is
# what the packages pages read the icon off.
files
|> Enum.group_by(& &1.package_version.package_id)
|> Enum.map(fn {_id, files} ->
Enum.max_by(files, &{pushed_at(&1), &1.package_version.id, &1.id})
end)
|> Enum.sort_by(& &1.id)
end
end
@doc "One line about a `backfill_icons/1` report, for a terminal."
@spec describe_backfill(map()) :: String.t()
def describe_backfill(%{error: reason, filename: filename}),
do: "#{filename}: could not read — #{inspect(reason)}"
def describe_backfill(%{filename: filename} = report) do
icon =
case report.icon do
{size, ext} -> "#{size} byte .#{ext} icon"
:none -> "no icon"
end
name = report.label || "no desktop entry name"
suffix = if report.written?, do: "", else: " — not written"
"#{filename}: #{name}, #{icon}#{suffix}"
end
defp pushed_at(%PackageFile{package_version: %{pushed_at: %DateTime{} = at}}),
do: DateTime.to_unix(at)
defp pushed_at(_file), do: 0
defp backfill_file(%PackageFile{} = file, dry?) do
package = file.package_version.package
report = %{format: package.format, package: package.name, filename: file.filename}
case Packages.read_file(file) do
{:ok, body} ->
found =
case read(package.format, body, package.name) do
{:ok, found} -> found
:none -> %{label: nil, icon: nil}
end
additions = if dry?, do: %{}, else: found |> stored() |> Map.take(@backfilled)
unless additions == %{} do
file
|> Ecto.Changeset.change(metadata: Map.merge(file.metadata || %{}, additions))
|> GitGud.Repo.update!()
end
{:ok,
Map.merge(report, %{
label: found.label,
icon: if(found.icon, do: {byte_size(found.icon), "png"}, else: :none),
written?: additions != %{}
})}
{:error, reason} ->
{:error, Map.put(report, :error, reason)}
end
end
# ── desktop entries ─────────────────────────────────────────────────
defp desktop_entries(entries) do
for {path, _} <- entries,
String.match?(path, ~r{/share/applications/[^/]+\.desktop\z}),
bytes = resolve(entries, path),
is_binary(bytes),
entry = parse(bytes),
entry.type in [nil, "Application"] do
Map.put(entry, :id, Path.basename(path, ".desktop"))
end
end
# The [Desktop Entry] group's unlocalised keys. Other groups
# (`[Desktop Action new-window]`) have keys of the same names that
# mean something else.
@doc false
def parse(text) do
text
|> String.split(["\r\n", "\n"])
|> Enum.reduce({nil, %{}}, fn line, {group, keys} ->
line = String.trim(line)
cond do
String.starts_with?(line, "[") ->
{String.trim(line, "[") |> String.trim_trailing("]"), keys}
group == "Desktop Entry" ->
case String.split(line, "=", parts: 2) do
[key, value] -> {group, Map.put_new(keys, String.trim(key), String.trim(value))}
_ -> {group, keys}
end
true ->
{group, keys}
end
end)
|> elem(1)
|> then(fn keys ->
%{
name: present(keys["Name"]),
icon: present(keys["Icon"]),
type: present(keys["Type"]),
hidden?: keys["NoDisplay"] == "true" or keys["Hidden"] == "true"
}
end)
end
# Lower sorts first: visible before hidden, named for the package
# before not, then by id for a stable answer.
defp rank(entry, package) do
{if(entry.hidden?, do: 1, else: 0), if(matches?(entry.id, package), do: 0, else: 1), entry.id}
end
# `koco.desktop` for `koco`, but also `org.neiam.KoCo.desktop` for
# `neiam-koco` — reverse-DNS ids are the norm now, and a package name
# often carries a vendor prefix the id doesn't.
defp matches?(id, package) do
id = String.downcase(id)
last = id |> String.split(".") |> List.last()
package = String.downcase(package)
id == package or last == package or String.ends_with?(package, "-" <> last)
end
# ── icons ───────────────────────────────────────────────────────────
defp icon("/" <> _ = path, entries), do: entries |> resolve(path) |> png()
defp icon(name, entries) do
file = Path.rootname(name, Path.extname(name)) <> ".png"
for {path, _} <- entries,
Path.basename(path) == file,
String.match?(path, ~r{/share/(icons|pixmaps)/}),
bytes = png(resolve(entries, path)),
bytes != nil do
{width(bytes), bytes}
end
|> Enum.max_by(&preference/1, fn -> nil end)
|> case do
{_width, bytes} -> bytes
nil -> nil
end
end
defp preference({width, _}) when width <= @preferred_max, do: {1, width}
defp preference({width, _}), do: {0, -width}
defp png(<<@png, _::binary>> = bytes) when byte_size(bytes) <= @max_icon_bytes, do: bytes
defp png(_), do: nil
# IHDR is always first: 8 bytes of signature, 4 of length, 4 of type,
# then the width.
defp width(<<@png, _len::32, "IHDR", width::32, _::binary>>), do: width
defp width(_), do: 0
# A path's bytes, through up to eight links. Relative symlink targets
# are relative to the link's directory.
defp resolve(entries, path, hops \\ 8)
defp resolve(_entries, _path, 0), do: nil
defp resolve(entries, path, hops) do
case Map.get(entries, path) do
{:file, bytes} ->
bytes
{:hardlink, target} ->
resolve(entries, target, hops - 1)
{:symlink, "/" <> _ = target} ->
resolve(entries, expand(target), hops - 1)
{:symlink, target} ->
resolve(entries, expand(Path.join(Path.dirname(path), target)), hops - 1)
nil ->
nil
end
end
# `..` resolved lexically, which is what a symlink inside a package
# can only mean — there is no filesystem underneath to ask.
defp expand(path) do
path
|> String.split("/", trim: true)
|> Enum.reduce([], fn
".", acc -> acc
"..", [] -> []
"..", [_ | acc] -> acc
part, acc -> [part | acc]
end)
|> Enum.reverse()
|> then(&("/" <> Enum.join(&1, "/")))
end
defp present(nil), do: nil
defp present(text) do
case String.trim(text) do
"" -> nil
text -> text
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
14.3 KiB · text
History
69f26e0