14.3 KiB · text History 69f26e0
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