neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Packages: show .deb and .rpm icons from their desktop entry
8a09017 · Gabriel Morell · 2026-10-15 03:18
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/packages.ex
+30
−1
| 270 | 270 | A package's icon — `{:ok, content_type, bytes, sha256}` — or `:none`. |
| 271 | 271 | |
| 272 | 272 | Generic over formats: any adapter that writes `icon_key` into a |
| 273 | − file's metadata at publish time (today, F-Droid's launcher icon) gets | |
| 273 | + file's metadata at publish time (an APK's launcher icon, a .deb or | |
| 274 | + .rpm's desktop entry icon) gets | |
| 274 | 275 | a picture on the packages pages. The newest file's, which is the one |
| 275 | 276 | an index would show. |
| 276 | 277 | """ |
| 288 | 289 | defp icon_content_type("jpg"), do: "image/jpeg" |
| 289 | 290 | defp icon_content_type(_), do: "image/png" |
| 290 | 291 | |
| 292 | + @doc """ | |
| 293 | + Store a package icon pulled out of an artifact at publish time, and | |
| 294 | + return the metadata keys that point `icon/1` at it — or `:error`. | |
| 295 | + | |
| 296 | + Keyed by content, so the same image published a hundred times is | |
| 297 | + stored once. `prefix` is only where it lands; F-Droid's icons keep | |
| 298 | + the prefix they were first stored under. | |
| 299 | + """ | |
| 300 | + @spec store_icon(binary(), String.t(), String.t()) :: {:ok, map()} | :error | |
| 301 | + def store_icon(bytes, ext, prefix \\ "package-icons") when is_binary(bytes) do | |
| 302 | + sha256 = :sha256 |> :crypto.hash(bytes) |> Base.encode16(case: :lower) | |
| 303 | + key = "#{prefix}/#{sha256}" | |
| 304 | + | |
| 305 | + case Storage.put(@artifact_bucket, key, bytes, content_type: icon_content_type(ext)) do | |
| 306 | + :ok -> | |
| 307 | + {:ok, | |
| 308 | + %{ | |
| 309 | + "icon_key" => key, | |
| 310 | + "icon_sha256" => sha256, | |
| 311 | + "icon_size" => byte_size(bytes), | |
| 312 | + "icon_ext" => ext | |
| 313 | + }} | |
| 314 | + | |
| 315 | + {:error, _} -> | |
| 316 | + :error | |
| 317 | + end | |
| 318 | + end | |
| 319 | + | |
| 291 | 320 | @doc "Look up a single file of a package by filename." |
| 292 | 321 | def get_package_file(%Package{id: pid}, filename) when is_binary(filename) do |
| 293 | 322 | from(f in PackageFile, |
modified
lib/git_gud/packages/apt/native.ex
+13
−0
| 90 | 90 | @doc "Decompress a zstd stream." |
| 91 | 91 | @spec zstd_decompress(binary()) :: {:ok, binary()} | {:error, String.t()} |
| 92 | 92 | def zstd_decompress(_data), do: :erlang.nif_error(:nif_not_loaded) |
| 93 | + | |
| 94 | + @doc """ | |
| 95 | + Decompress an xz stream, or fail once the output passes `limit` | |
| 96 | + bytes — for input whose decompressed size nothing else bounds. | |
| 97 | + """ | |
| 98 | + @spec xz_decompress_capped(binary(), non_neg_integer()) :: | |
| 99 | + {:ok, binary()} | {:error, String.t()} | |
| 100 | + def xz_decompress_capped(_data, _limit), do: :erlang.nif_error(:nif_not_loaded) | |
| 101 | + | |
| 102 | + @doc "As `xz_decompress_capped/2`, for zstd." | |
| 103 | + @spec zstd_decompress_capped(binary(), non_neg_integer()) :: | |
| 104 | + {:ok, binary()} | {:error, String.t()} | |
| 105 | + def zstd_decompress_capped(_data, _limit), do: :erlang.nif_error(:nif_not_loaded) | |
| 93 | 106 | end |
modified
lib/git_gud/packages/formats/deb.ex
+8
−6
| 70 | 70 | alias GitGud.Packages |
| 71 | 71 | alias GitGud.Packages.Apt.Native |
| 72 | 72 | alias GitGud.Packages.Formats.Deb.Control |
| 73 | + alias GitGud.Packages.Formats.Desktop | |
| 73 | 74 | alias GitGud.Packages.Formats.Listing |
| 74 | 75 | alias GitGud.Packages.PackageFile |
| 75 | 76 | alias GitGud.Repositories.Repository |
| 394 | 395 | filename: "#{name}_#{version}_#{architecture}.deb", |
| 395 | 396 | body: body, |
| 396 | 397 | content_type: content_type(name), |
| 397 | − metadata: %{ | |
| 398 | − "dist" => dist, | |
| 399 | − "component" => component, | |
| 400 | − "architecture" => architecture, | |
| 401 | − "control" => control | |
| 402 | − }, | |
| 398 | + metadata: | |
| 399 | + Map.merge(Desktop.metadata("deb", body, name), %{ | |
| 400 | + "dist" => dist, | |
| 401 | + "component" => component, | |
| 402 | + "architecture" => architecture, | |
| 403 | + "control" => control | |
| 404 | + }), | |
| 403 | 405 | version_metadata: %{"control" => control}, |
| 404 | 406 | uploader: uploader(ctx) |
| 405 | 407 | } |
modified
lib/git_gud/packages/formats/deb/control.ex
+31
−3
| 8 | 8 | control.tar.{gz,xz,zst} |
| 9 | 9 | data.tar.{gz,xz,zst} |
| 10 | 10 | |
| 11 | − Only the control member is read. Inside it is a `control` file | |
| 12 | − holding one RFC 822-ish stanza: `Package`, `Version`, | |
| 13 | − `Architecture`, `Depends`, `Description` and friends. | |
| 11 | + `parse/1` reads only the control member. Inside it is a `control` | |
| 12 | + file holding one RFC 822-ish stanza: `Package`, `Version`, | |
| 13 | + `Architecture`, `Depends`, `Description` and friends. `data/1` hands | |
| 14 | + back the data member, still compressed, for the one thing the index | |
| 15 | + wants from the payload — the desktop entry's icon. | |
| 14 | 16 | |
| 15 | 17 | `ar` is parsed here rather than shelled out to, because `ar` is |
| 16 | 18 | sixty bytes of fixed-width ASCII per member and shelling out would |
| 41 | 43 | |
| 42 | 44 | def parse(_), do: {:error, {400, "not an ar archive — a .deb starts with !<arch>"}} |
| 43 | 45 | |
| 46 | + @doc """ | |
| 47 | + The `data.tar` member of a `.deb`, as `{:ok, compression, blob}` — | |
| 48 | + `compression` being `:gzip`, `:xz`, `:zstd` or `:none` — or `:error`. | |
| 49 | + Left compressed: whether to pay for decompressing it is the caller's | |
| 50 | + call. | |
| 51 | + """ | |
| 52 | + @spec data(binary()) :: {:ok, atom(), binary()} | :error | |
| 53 | + def data(<<@magic, rest::binary>>) do | |
| 54 | + with {:ok, members} <- ar_members(rest, %{}), | |
| 55 | + {name, blob} <- | |
| 56 | + Enum.find(members, fn {name, _} -> String.starts_with?(name, "data.tar") end), | |
| 57 | + kind when kind != nil <- compression(Path.extname(name)) do | |
| 58 | + {:ok, kind, blob} | |
| 59 | + else | |
| 60 | + _ -> :error | |
| 61 | + end | |
| 62 | + end | |
| 63 | + | |
| 64 | + def data(_), do: :error | |
| 65 | + | |
| 66 | + defp compression(".gz"), do: :gzip | |
| 67 | + defp compression(".xz"), do: :xz | |
| 68 | + defp compression(".zst"), do: :zstd | |
| 69 | + defp compression(".tar"), do: :none | |
| 70 | + defp compression(_), do: nil | |
| 71 | + | |
| 44 | 72 | # ── ar ────────────────────────────────────────────────────────────── |
| 45 | 73 | |
| 46 | 74 | # Each member: a 60-byte header (name[16] mtime[12] uid[6] gid[6] |
added
lib/git_gud/packages/formats/desktop.ex
+456
−0
| 1 | +defmodule GitGud.Packages.Formats.Desktop do | |
| 2 | + @moduledoc """ | |
| 3 | + A Linux package's name and icon, as its desktop entry gives them — | |
| 4 | + the `.deb`/`.rpm` counterpart of an APK's `android:label` and | |
| 5 | + launcher icon. | |
| 6 | + | |
| 7 | + A package that installs an application ships a desktop entry, | |
| 8 | + `/usr/share/applications/<id>.desktop`, whose `Name=` is what a | |
| 9 | + launcher shows and whose `Icon=` names an image the package ships | |
| 10 | + alongside it: | |
| 11 | + | |
| 12 | + [Desktop Entry] | |
| 13 | + Type=Application | |
| 14 | + Name=KoCo | |
| 15 | + Icon=koco | |
| 16 | + → /usr/share/icons/hicolor/256x256/apps/koco.png | |
| 17 | + | |
| 18 | + `Icon=` is either an absolute path or a theme name. A name is looked | |
| 19 | + up the way the icon theme spec does it, cut down to what the package | |
| 20 | + itself contains: `<name>.png` under any `share/icons/` theme or | |
| 21 | + `share/pixmaps/`, the largest that is still a sensible size. Only | |
| 22 | + PNG: an SVG would need rasterising or serving user-supplied SVG to | |
| 23 | + browsers, and the mobile client can't draw one either. | |
| 24 | + | |
| 25 | + Read once at publish, by the adapters — the icon is stored the way | |
| 26 | + F-Droid's is, under `icon_key`, so `GitGud.Packages.icon/1` and the | |
| 27 | + catalog need nothing new. Packages published before that are filled | |
| 28 | + in by `backfill_icons/1`. | |
| 29 | + """ | |
| 30 | + | |
| 31 | + alias GitGud.Packages | |
| 32 | + alias GitGud.Packages.Apt.Native | |
| 33 | + alias GitGud.Packages.Formats.Deb.Control | |
| 34 | + alias GitGud.Packages.Formats.Rpm.Cpio | |
| 35 | + alias GitGud.Packages.Formats.Rpm.Header | |
| 36 | + alias GitGud.Packages.Formats.Tar | |
| 37 | + alias GitGud.Packages.PackageFile | |
| 38 | + | |
| 39 | + # Decompressed payloads past this are not looked inside. An icon is | |
| 40 | + # decoration, and the payload is uploader-controlled: a few kilobytes | |
| 41 | + # of xz can claim to be gigabytes. | |
| 42 | + @payload_limit 256 * 1024 * 1024 | |
| 43 | + | |
| 44 | + # Biggest icon worth keeping. Bigger than this is a splash screen. | |
| 45 | + @max_icon_bytes 1024 * 1024 | |
| 46 | + | |
| 47 | + # Preferred ceiling on edge length; anything up to it, the larger the | |
| 48 | + # better. Past it, the smallest. | |
| 49 | + @preferred_max 512 | |
| 50 | + | |
| 51 | + @png <<0x89, "PNG\r\n", 0x1A, "\n">> | |
| 52 | + | |
| 53 | + @doc """ | |
| 54 | + Decompress a package payload, giving up past `@payload_limit`. | |
| 55 | + `kind` is `:gzip`, `:xz`, `:zstd` or `:none`. | |
| 56 | + """ | |
| 57 | + @spec decompress(atom(), binary()) :: {:ok, binary()} | :error | |
| 58 | + def decompress(:none, blob), do: {:ok, blob} | |
| 59 | + def decompress(:gzip, blob), do: gunzip(blob) | |
| 60 | + def decompress(:xz, blob), do: nif(Native.xz_decompress_capped(blob, @payload_limit)) | |
| 61 | + def decompress(:zstd, blob), do: nif(Native.zstd_decompress_capped(blob, @payload_limit)) | |
| 62 | + def decompress(_kind, _blob), do: :error | |
| 63 | + | |
| 64 | + defp nif({:ok, bytes}), do: {:ok, bytes} | |
| 65 | + defp nif(_), do: :error | |
| 66 | + | |
| 67 | + # `:zlib.gunzip/1` has no ceiling; inflating a chunk at a time does. | |
| 68 | + defp gunzip(blob) do | |
| 69 | + z = :zlib.open() | |
| 70 | + | |
| 71 | + try do | |
| 72 | + :ok = :zlib.inflateInit(z, 31) | |
| 73 | + inflate(z, :zlib.safeInflate(z, blob), [], 0) | |
| 74 | + rescue | |
| 75 | + _ -> :error | |
| 76 | + after | |
| 77 | + :zlib.close(z) | |
| 78 | + end | |
| 79 | + end | |
| 80 | + | |
| 81 | + defp inflate(z, {status, out}, acc, size) do | |
| 82 | + size = size + IO.iodata_length(out) | |
| 83 | + | |
| 84 | + cond do | |
| 85 | + size > @payload_limit -> :error | |
| 86 | + status == :finished -> {:ok, IO.iodata_to_binary([acc, out])} | |
| 87 | + true -> inflate(z, :zlib.safeInflate(z, []), [acc, out], size) | |
| 88 | + end | |
| 89 | + end | |
| 90 | + | |
| 91 | + @doc """ | |
| 92 | + What the desktop entry in a `.deb` or `.rpm` says, as metadata to | |
| 93 | + merge into its package file's: `label`, and the `icon_*` keys with | |
| 94 | + the icon stored. Empty when the package carries no desktop entry, or | |
| 95 | + nothing usable — an icon is decoration, and nothing here fails a | |
| 96 | + publish. | |
| 97 | + | |
| 98 | + `package` is the package name, which picks between several entries. | |
| 99 | + """ | |
| 100 | + @spec metadata(String.t(), binary(), String.t()) :: map() | |
| 101 | + def metadata(format, body, package) do | |
| 102 | + case read(format, body, package) do | |
| 103 | + {:ok, found} -> stored(found) | |
| 104 | + :none -> %{} | |
| 105 | + end | |
| 106 | + end | |
| 107 | + | |
| 108 | + defp stored(%{label: label, icon: icon}) do | |
| 109 | + label = if label, do: %{"label" => label}, else: %{} | |
| 110 | + Map.merge(label, store(icon)) | |
| 111 | + end | |
| 112 | + | |
| 113 | + @doc """ | |
| 114 | + The chosen desktop entry's `Name=` and icon bytes in a `.deb` or | |
| 115 | + `.rpm`, without storing anything — or `:none`. | |
| 116 | + """ | |
| 117 | + @spec read(String.t(), binary(), String.t()) :: | |
| 118 | + {:ok, %{label: String.t() | nil, icon: binary() | nil}} | :none | |
| 119 | + def read(format, body, package) do | |
| 120 | + case entries(format, body) do | |
| 121 | + {:ok, entries} -> find(entries, package) | |
| 122 | + _ -> :none | |
| 123 | + end | |
| 124 | + end | |
| 125 | + | |
| 126 | + defp entries("deb", body) do | |
| 127 | + with {:ok, kind, blob} <- Control.data(body), | |
| 128 | + {:ok, tar} <- decompress(kind, blob), | |
| 129 | + do: Tar.entries(tar) | |
| 130 | + end | |
| 131 | + | |
| 132 | + # The header lists every file, so an RPM with no desktop entry — most | |
| 133 | + # of them — is known not to have one without decompressing anything. | |
| 134 | + defp entries("rpm", body) do | |
| 135 | + with {:ok, header} <- Header.parse(body), | |
| 136 | + true <- Enum.any?(Header.files(header), &String.ends_with?(&1, ".desktop")), | |
| 137 | + {:ok, kind, blob} <- Header.payload(body, header), | |
| 138 | + {:ok, cpio} <- decompress(kind, blob), | |
| 139 | + do: Cpio.entries(cpio) | |
| 140 | + end | |
| 141 | + | |
| 142 | + defp entries(_format, _body), do: :error | |
| 143 | + | |
| 144 | + defp store(nil), do: %{} | |
| 145 | + | |
| 146 | + defp store(bytes) do | |
| 147 | + case Packages.store_icon(bytes, "png") do | |
| 148 | + {:ok, meta} -> meta | |
| 149 | + # An icon is decoration; a publish doesn't fail over one. | |
| 150 | + :error -> %{} | |
| 151 | + end | |
| 152 | + end | |
| 153 | + | |
| 154 | + @doc """ | |
| 155 | + The chosen desktop entry's `Name=` and icon bytes, or `:none`. | |
| 156 | + """ | |
| 157 | + @spec find(map(), String.t()) :: {:ok, %{label: String.t() | nil, icon: binary() | nil}} | :none | |
| 158 | + def find(entries, package) do | |
| 159 | + entries | |
| 160 | + |> desktop_entries() | |
| 161 | + |> Enum.sort_by(&rank(&1, package)) | |
| 162 | + |> case do | |
| 163 | + [] -> | |
| 164 | + :none | |
| 165 | + | |
| 166 | + [first | _] = ranked -> | |
| 167 | + # The first entry that resolves to an icon, for the icon *and* | |
| 168 | + # the name — a name from one entry over another's icon would be | |
| 169 | + # a picture of something else. Failing every icon, the | |
| 170 | + # best-ranked entry still names the package. | |
| 171 | + case Enum.find_value(ranked, &with_icon(&1, entries)) do | |
| 172 | + nil -> {:ok, %{label: first.name, icon: nil}} | |
| 173 | + found -> {:ok, found} | |
| 174 | + end | |
| 175 | + end | |
| 176 | + end | |
| 177 | + | |
| 178 | + defp with_icon(%{icon: nil}, _entries), do: nil | |
| 179 | + | |
| 180 | + defp with_icon(entry, entries) do | |
| 181 | + case icon(entry.icon, entries) do | |
| 182 | + nil -> nil | |
| 183 | + bytes -> %{label: entry.name, icon: bytes} | |
| 184 | + end | |
| 185 | + end | |
| 186 | + | |
| 187 | + # ── backfill ──────────────────────────────────────────────────────── | |
| 188 | + | |
| 189 | + @formats ~w(deb rpm) | |
| 190 | + | |
| 191 | + # Only these come from the re-read. | |
| 192 | + @backfilled ~w(label icon_key icon_sha256 icon_size icon_ext) | |
| 193 | + | |
| 194 | + @doc """ | |
| 195 | + Re-read published `.deb`s and `.rpm`s and record the desktop entry's | |
| 196 | + name and icon on each, for packages published before the adapters | |
| 197 | + read them. | |
| 198 | + | |
| 199 | + Options: | |
| 200 | + | |
| 201 | + * `:all` — every file, not just each package's newest. The newest | |
| 202 | + is the default because it is the only one the packages pages | |
| 203 | + read. | |
| 204 | + * `:dry_run` — report, write nothing. | |
| 205 | + * `:on_file` — called with a report map per file. | |
| 206 | + | |
| 207 | + Returns `{read, written}`. Idempotent: icons are stored by content, | |
| 208 | + and a package with no desktop entry is read and left alone. | |
| 209 | + """ | |
| 210 | + @spec backfill_icons(keyword()) :: {non_neg_integer(), non_neg_integer()} | |
| 211 | + def backfill_icons(opts \\ []) do | |
| 212 | + all? = Keyword.get(opts, :all, false) | |
| 213 | + dry? = Keyword.get(opts, :dry_run, false) | |
| 214 | + on_file = Keyword.get(opts, :on_file, fn _ -> :ok end) | |
| 215 | + | |
| 216 | + all? | |
| 217 | + |> backfill_candidates() | |
| 218 | + |> Enum.reduce({0, 0}, fn file, {read, written} -> | |
| 219 | + case backfill_file(file, dry?) do | |
| 220 | + {:ok, report} -> | |
| 221 | + on_file.(report) | |
| 222 | + {read + 1, if(report.written?, do: written + 1, else: written)} | |
| 223 | + | |
| 224 | + {:error, report} -> | |
| 225 | + on_file.(report) | |
| 226 | + {read + 1, written} | |
| 227 | + end | |
| 228 | + end) | |
| 229 | + end | |
| 230 | + | |
| 231 | + defp backfill_candidates(all?) do | |
| 232 | + import Ecto.Query | |
| 233 | + | |
| 234 | + files = | |
| 235 | + from(f in PackageFile, | |
| 236 | + join: v in assoc(f, :package_version), | |
| 237 | + join: p in assoc(v, :package), | |
| 238 | + where: p.format in @formats, | |
| 239 | + preload: [package_version: {v, package: p}] | |
| 240 | + ) | |
| 241 | + |> GitGud.Repo.all() | |
| 242 | + | |
| 243 | + if all? do | |
| 244 | + Enum.sort_by(files, & &1.id) | |
| 245 | + else | |
| 246 | + # The same "newest" `Packages.latest_files/1` picks, which is | |
| 247 | + # what the packages pages read the icon off. | |
| 248 | + files | |
| 249 | + |> Enum.group_by(& &1.package_version.package_id) | |
| 250 | + |> Enum.map(fn {_id, files} -> | |
| 251 | + Enum.max_by(files, &{pushed_at(&1), &1.package_version.id, &1.id}) | |
| 252 | + end) | |
| 253 | + |> Enum.sort_by(& &1.id) | |
| 254 | + end | |
| 255 | + end | |
| 256 | + | |
| 257 | + @doc "One line about a `backfill_icons/1` report, for a terminal." | |
| 258 | + @spec describe_backfill(map()) :: String.t() | |
| 259 | + def describe_backfill(%{error: reason, filename: filename}), | |
| 260 | + do: "#{filename}: could not read — #{inspect(reason)}" | |
| 261 | + | |
| 262 | + def describe_backfill(%{filename: filename} = report) do | |
| 263 | + icon = | |
| 264 | + case report.icon do | |
| 265 | + {size, ext} -> "#{size} byte .#{ext} icon" | |
| 266 | + :none -> "no icon" | |
| 267 | + end | |
| 268 | + | |
| 269 | + name = report.label || "no desktop entry name" | |
| 270 | + suffix = if report.written?, do: "", else: " — not written" | |
| 271 | + | |
| 272 | + "#{filename}: #{name}, #{icon}#{suffix}" | |
| 273 | + end | |
| 274 | + | |
| 275 | + defp pushed_at(%PackageFile{package_version: %{pushed_at: %DateTime{} = at}}), | |
| 276 | + do: DateTime.to_unix(at) | |
| 277 | + | |
| 278 | + defp pushed_at(_file), do: 0 | |
| 279 | + | |
| 280 | + defp backfill_file(%PackageFile{} = file, dry?) do | |
| 281 | + package = file.package_version.package | |
| 282 | + report = %{format: package.format, package: package.name, filename: file.filename} | |
| 283 | + | |
| 284 | + case Packages.read_file(file) do | |
| 285 | + {:ok, body} -> | |
| 286 | + found = | |
| 287 | + case read(package.format, body, package.name) do | |
| 288 | + {:ok, found} -> found | |
| 289 | + :none -> %{label: nil, icon: nil} | |
| 290 | + end | |
| 291 | + | |
| 292 | + additions = if dry?, do: %{}, else: found |> stored() |> Map.take(@backfilled) | |
| 293 | + | |
| 294 | + unless additions == %{} do | |
| 295 | + file | |
| 296 | + |> Ecto.Changeset.change(metadata: Map.merge(file.metadata || %{}, additions)) | |
| 297 | + |> GitGud.Repo.update!() | |
| 298 | + end | |
| 299 | + | |
| 300 | + {:ok, | |
| 301 | + Map.merge(report, %{ | |
| 302 | + label: found.label, | |
| 303 | + icon: if(found.icon, do: {byte_size(found.icon), "png"}, else: :none), | |
| 304 | + written?: additions != %{} | |
| 305 | + })} | |
| 306 | + | |
| 307 | + {:error, reason} -> | |
| 308 | + {:error, Map.put(report, :error, reason)} | |
| 309 | + end | |
| 310 | + end | |
| 311 | + | |
| 312 | + # ── desktop entries ───────────────────────────────────────────────── | |
| 313 | + | |
| 314 | + defp desktop_entries(entries) do | |
| 315 | + for {path, _} <- entries, | |
| 316 | + String.match?(path, ~r{/share/applications/[^/]+\.desktop\z}), | |
| 317 | + bytes = resolve(entries, path), | |
| 318 | + is_binary(bytes), | |
| 319 | + entry = parse(bytes), | |
| 320 | + entry.type in [nil, "Application"] do | |
| 321 | + Map.put(entry, :id, Path.basename(path, ".desktop")) | |
| 322 | + end | |
| 323 | + end | |
| 324 | + | |
| 325 | + # The [Desktop Entry] group's unlocalised keys. Other groups | |
| 326 | + # (`[Desktop Action new-window]`) have keys of the same names that | |
| 327 | + # mean something else. | |
| 328 | + @doc false | |
| 329 | + def parse(text) do | |
| 330 | + text | |
| 331 | + |> String.split(["\r\n", "\n"]) | |
| 332 | + |> Enum.reduce({nil, %{}}, fn line, {group, keys} -> | |
| 333 | + line = String.trim(line) | |
| 334 | + | |
| 335 | + cond do | |
| 336 | + String.starts_with?(line, "[") -> | |
| 337 | + {String.trim(line, "[") |> String.trim_trailing("]"), keys} | |
| 338 | + | |
| 339 | + group == "Desktop Entry" -> | |
| 340 | + case String.split(line, "=", parts: 2) do | |
| 341 | + [key, value] -> {group, Map.put_new(keys, String.trim(key), String.trim(value))} | |
| 342 | + _ -> {group, keys} | |
| 343 | + end | |
| 344 | + | |
| 345 | + true -> | |
| 346 | + {group, keys} | |
| 347 | + end | |
| 348 | + end) | |
| 349 | + |> elem(1) | |
| 350 | + |> then(fn keys -> | |
| 351 | + %{ | |
| 352 | + name: present(keys["Name"]), | |
| 353 | + icon: present(keys["Icon"]), | |
| 354 | + type: present(keys["Type"]), | |
| 355 | + hidden?: keys["NoDisplay"] == "true" or keys["Hidden"] == "true" | |
| 356 | + } | |
| 357 | + end) | |
| 358 | + end | |
| 359 | + | |
| 360 | + # Lower sorts first: visible before hidden, named for the package | |
| 361 | + # before not, then by id for a stable answer. | |
| 362 | + defp rank(entry, package) do | |
| 363 | + {if(entry.hidden?, do: 1, else: 0), if(matches?(entry.id, package), do: 0, else: 1), entry.id} | |
| 364 | + end | |
| 365 | + | |
| 366 | + # `koco.desktop` for `koco`, but also `org.neiam.KoCo.desktop` for | |
| 367 | + # `neiam-koco` — reverse-DNS ids are the norm now, and a package name | |
| 368 | + # often carries a vendor prefix the id doesn't. | |
| 369 | + defp matches?(id, package) do | |
| 370 | + id = String.downcase(id) | |
| 371 | + last = id |> String.split(".") |> List.last() | |
| 372 | + package = String.downcase(package) | |
| 373 | + | |
| 374 | + id == package or last == package or String.ends_with?(package, "-" <> last) | |
| 375 | + end | |
| 376 | + | |
| 377 | + # ── icons ─────────────────────────────────────────────────────────── | |
| 378 | + | |
| 379 | + defp icon("/" <> _ = path, entries), do: entries |> resolve(path) |> png() | |
| 380 | + | |
| 381 | + defp icon(name, entries) do | |
| 382 | + file = Path.rootname(name, Path.extname(name)) <> ".png" | |
| 383 | + | |
| 384 | + for {path, _} <- entries, | |
| 385 | + Path.basename(path) == file, | |
| 386 | + String.match?(path, ~r{/share/(icons|pixmaps)/}), | |
| 387 | + bytes = png(resolve(entries, path)), | |
| 388 | + bytes != nil do | |
| 389 | + {width(bytes), bytes} | |
| 390 | + end | |
| 391 | + |> Enum.max_by(&preference/1, fn -> nil end) | |
| 392 | + |> case do | |
| 393 | + {_width, bytes} -> bytes | |
| 394 | + nil -> nil | |
| 395 | + end | |
| 396 | + end | |
| 397 | + | |
| 398 | + defp preference({width, _}) when width <= @preferred_max, do: {1, width} | |
| 399 | + defp preference({width, _}), do: {0, -width} | |
| 400 | + | |
| 401 | + defp png(<<@png, _::binary>> = bytes) when byte_size(bytes) <= @max_icon_bytes, do: bytes | |
| 402 | + defp png(_), do: nil | |
| 403 | + | |
| 404 | + # IHDR is always first: 8 bytes of signature, 4 of length, 4 of type, | |
| 405 | + # then the width. | |
| 406 | + defp width(<<@png, _len::32, "IHDR", width::32, _::binary>>), do: width | |
| 407 | + defp width(_), do: 0 | |
| 408 | + | |
| 409 | + # A path's bytes, through up to eight links. Relative symlink targets | |
| 410 | + # are relative to the link's directory. | |
| 411 | + defp resolve(entries, path, hops \\ 8) | |
| 412 | + defp resolve(_entries, _path, 0), do: nil | |
| 413 | + | |
| 414 | + defp resolve(entries, path, hops) do | |
| 415 | + case Map.get(entries, path) do | |
| 416 | + {:file, bytes} -> | |
| 417 | + bytes | |
| 418 | + | |
| 419 | + {:hardlink, target} -> | |
| 420 | + resolve(entries, target, hops - 1) | |
| 421 | + | |
| 422 | + {:symlink, "/" <> _ = target} -> | |
| 423 | + resolve(entries, expand(target), hops - 1) | |
| 424 | + | |
| 425 | + {:symlink, target} -> | |
| 426 | + resolve(entries, expand(Path.join(Path.dirname(path), target)), hops - 1) | |
| 427 | + | |
| 428 | + nil -> | |
| 429 | + nil | |
| 430 | + end | |
| 431 | + end | |
| 432 | + | |
| 433 | + # `..` resolved lexically, which is what a symlink inside a package | |
| 434 | + # can only mean — there is no filesystem underneath to ask. | |
| 435 | + defp expand(path) do | |
| 436 | + path | |
| 437 | + |> String.split("/", trim: true) | |
| 438 | + |> Enum.reduce([], fn | |
| 439 | + ".", acc -> acc | |
| 440 | + "..", [] -> [] | |
| 441 | + "..", [_ | acc] -> acc | |
| 442 | + part, acc -> [part | acc] | |
| 443 | + end) | |
| 444 | + |> Enum.reverse() | |
| 445 | + |> then(&("/" <> Enum.join(&1, "/"))) | |
| 446 | + end | |
| 447 | + | |
| 448 | + defp present(nil), do: nil | |
| 449 | + | |
| 450 | + defp present(text) do | |
| 451 | + case String.trim(text) do | |
| 452 | + "" -> nil | |
| 453 | + text -> text | |
| 454 | + end | |
| 455 | + end | |
| 456 | +end |
modified
lib/git_gud/packages/formats/fdroid.ex
+7
−23
Click to load diff…
modified
lib/git_gud/packages/formats/rpm.ex
+2
−1
Click to load diff…
added
lib/git_gud/packages/formats/rpm/cpio.ex
+76
−0
Click to load diff…
modified
lib/git_gud/packages/formats/rpm/header.ex
+28
−3
Click to load diff…
added
lib/git_gud/packages/formats/tar.ex
+137
−0
Click to load diff…
modified
lib/git_gud/release.ex
+37
−0
Click to load diff…
added
lib/mix/tasks/gitgud.backfill_desktop_icons.ex
+51
−0
Click to load diff…
modified
native/gitgud_apt/src/lib.rs
+71
−0
Click to load diff…
added
test/git_gud/packages/desktop_icon_test.exs
+303
−0
Click to load diff…
modified
test/support/fixtures/rpm_fixtures.ex
+41
−3
Click to load diff…
Parents: 7784d39