Add the F-Droid repository by scanning a QR

9f00bdd · Gabriel Morell · 2026-09-12 18:17

11 files +121 -11
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages/usage.ex
+3 −10
@@ -404,24 +404,17 @@ defmodule GitGud.Packages.Usage do
404 404 %{
405 405 consume: [
406 406 step(
407 − "Add #{ctx.handle} as a repository in F-Droid",
408 − "shell",
409 − """
410 − #{namespace}?fingerprint=<fingerprint>
411 − """
412 − ),
413 − step(
414 − "Get the fingerprint",
407 + "Read the fingerprint, for scripting or to check the one above",
415 408 "shell",
416 409 """
417 410 curl -fsSL #{key_url(ctx, "fdroid")}
418 411 """
419 412 ),
420 413 step(
421 − "…or add just this repository",
414 + "…or add just this repository, with the same fingerprint",
422 415 "shell",
423 416 """
424 − #{url}?fingerprint=<fingerprint>
417 + #{url}?fingerprint=$(curl -fsSL #{key_url(ctx, "fdroid")})
425 418 """
426 419 ),
427 420 step(
modified lib/git_gud_web/live/repo_live/packages.ex
+87 −1
@@ -79,6 +79,10 @@ defmodule GitGudWeb.RepoLive.Packages do
79 79 # common case needs no clicking.
80 80 |> assign(:help_format, default_help_format(groups))
81 81 |> assign(:help_facet, "manager")
82 + |> assign(:fdroid_repo, nil)
83 + # `default_help_format/1` can land on fdroid when that's what this
84 + # repository publishes, and then no tab click ever fires.
85 + |> then(&load_fdroid_repo(&1, &1.assigns.help_format))
82 86 |> assign(:page_title, "Packages — #{owner}/#{name}")}
83 87 end
84 88
@@ -166,7 +170,7 @@ defmodule GitGudWeb.RepoLive.Packages do
166 170 end
167 171
168 172 def handle_event("help-format", %{"format" => format}, socket),
169 − do: {:noreply, assign(socket, :help_format, format)}
173 + do: {:noreply, socket |> assign(:help_format, format) |> load_fdroid_repo(format)}
170 174
171 175 def handle_event("help-facet", %{"facet" => facet}, socket),
172 176 do: {:noreply, assign(socket, :help_facet, facet)}
@@ -465,6 +469,7 @@ defmodule GitGudWeb.RepoLive.Packages do
465 469 usage={Usage.for_format(@help_format, @usage_ctx)}
466 470 facet={@help_facet}
467 471 format={@help_format}
472 + fdroid_repo={@fdroid_repo}
468 473 handle={@handle}
469 474 repo_name={@repo.name}
470 475 />
@@ -478,6 +483,7 @@ defmodule GitGudWeb.RepoLive.Packages do
478 483 attr :usage, :map, default: nil
479 484 attr :facet, :string, required: true
480 485 attr :format, :string, required: true
486 + attr :fdroid_repo, :any, default: nil
481 487 attr :handle, :string, required: true
482 488 attr :repo_name, :string, required: true
483 489
@@ -510,6 +516,7 @@ defmodule GitGudWeb.RepoLive.Packages do
510 516 </p>
511 517 <.usage_steps steps={@usage.ci} />
512 518 <% else %>
519 + <.fdroid_qr :if={@format == "fdroid"} repo={@fdroid_repo} />
513 520 <.usage_group title="Install" steps={@usage.consume} />
514 521 <.usage_group title="Publish" steps={@usage.publish} />
515 522 <p class="text-xs opacity-60">
@@ -528,6 +535,48 @@ defmodule GitGudWeb.RepoLive.Packages do
528 535 """
529 536 end
530 537
538 + attr :repo, :any, default: nil
539 +
540 + # F-Droid's "add repository" screen has a scanner. This is the whole
541 + # of the setup on a phone: no typing a URL, and no typing a 64-hex
542 + # fingerprint, which is the part nobody would get right by hand.
543 + defp fdroid_qr(%{repo: nil} = assigns), do: ~H""
544 +
545 + defp fdroid_qr(%{repo: :unavailable} = assigns) do
546 + ~H"""
547 + <p class="text-xs opacity-60">
548 + Couldn't read this namespace's index signing certificate, so there's
549 + no QR to show. The instructions below still apply — get the
550 + fingerprint from <code class="font-mono">/pkg/_keys/fdroid/…</code> once it's reachable.
551 + </p>
552 + """
553 + end
554 +
555 + defp fdroid_qr(assigns) do
556 + ~H"""
557 + <div class="flex flex-wrap items-start gap-4">
558 + <div class="rounded bg-white p-1.5 leading-none shrink-0">
559 + {Phoenix.HTML.raw(@repo.qr)}
560 + </div>
561 + <div class="min-w-48 flex-1 space-y-1.5">
562 + <h3 class="text-xs font-semibold uppercase tracking-wide opacity-50">Scan to add</h3>
563 + <p class="text-xs opacity-70">
564 + F-Droid → Settings → Repositories → + → scan. One entry covers
565 + every Android app this namespace publishes.
566 + </p>
567 + <pre class="bg-base-200 rounded px-2.5 py-2 overflow-x-auto"><code
568 + class="text-[11px] leading-relaxed select-all whitespace-pre-wrap break-all"
569 + >{@repo.url}</code></pre>
570 + <p class="text-xs opacity-60">
571 + The <code class="font-mono">fingerprint</code>
572 + pins the index signing certificate — F-Droid refuses an index signed
573 + by anything else.
574 + </p>
575 + </div>
576 + </div>
577 + """
578 + end
579 +
531 580 attr :title, :string, required: true
532 581 attr :steps, :list, default: []
533 582
@@ -553,6 +602,43 @@ defmodule GitGudWeb.RepoLive.Packages do
553 602 """
554 603 end
555 604
605 + # F-Droid adds a repository by scanning a QR, and the URL it scans has
606 + # to carry the fingerprint of the index signing certificate — so this
607 + # needs the real fingerprint, not the `<fingerprint>` placeholder the
608 + # static instructions can offer.
609 + #
610 + # Loaded when the tab is opened rather than at mount, for two
611 + # reasons: asking for the fingerprint *mints the signing key* if the
612 + # namespace has none, and under OpenBao it is a network call. Neither
613 + # belongs on the render path of every packages page in the forge.
614 + defp load_fdroid_repo(socket, "fdroid") do
615 + if socket.assigns.fdroid_repo do
616 + socket
617 + else
618 + ctx = socket.assigns.usage_ctx
619 +
620 + case GitGud.Packages.Formats.Fdroid.fingerprint(socket.assigns.repo) do
621 + {:ok, fingerprint} ->
622 + url = "#{Usage.namespace_url(ctx, "fdroid")}?fingerprint=#{fingerprint}"
623 + assign(socket, :fdroid_repo, %{url: url, fingerprint: fingerprint, qr: qr_svg(url)})
624 +
625 + # A sealed or unreachable OpenBao, most likely. The rest of the
626 + # instructions are still correct and still useful, so the panel
627 + # renders without the QR rather than the page failing.
628 + {:error, _reason} ->
629 + assign(socket, :fdroid_repo, :unavailable)
630 + end
631 + end
632 + end
633 +
634 + defp load_fdroid_repo(socket, _format), do: socket
635 +
636 + defp qr_svg(url) do
637 + url
638 + |> EQRCode.encode()
639 + |> EQRCode.svg(width: 180, background_color: "#ffffff", color: "#000000")
640 + end
641 +
556 642 defp format_label(format), do: Map.get(@format_labels, format, format)
557 643
558 644 # The one-liner that installs a specific version, shown inline with
modified test/git_gud_web/package_usage_test.exs
+31 −0
@@ -170,6 +170,37 @@ defmodule GitGudWeb.PackageUsageTest do
170 170 end
171 171 end
172 172
173 + describe "the F-Droid QR" do
174 + setup %{conn: conn, path: path} do
175 + {:ok, view, _html} = live(conn, path)
176 + %{html: view |> element(~s{button[phx-value-format="fdroid"]}) |> render_click()}
177 + end
178 +
179 + test "renders a scannable code, not a placeholder", %{html: html} do
180 + # F-Droid's add-repository screen has a scanner, and the 64-hex
181 + # fingerprint is the part nobody would type correctly by hand.
182 + assert html =~ "<svg"
183 + assert html =~ "Scan to add"
184 +
185 + # A real fingerprint. The panel owns this URL precisely so no
186 + # step anywhere has to print a `<fingerprint>` placeholder for a
187 + # value nobody could fill in by hand.
188 + assert html =~ ~r/\?fingerprint=[0-9a-f]{64}/
189 + refute html =~ "&lt;fingerprint&gt;"
190 + end
191 +
192 + test "points at the namespace repository, not this one", %{html: html, handle: handle} do
193 + # One entry per publisher, not one per Android project.
194 + assert html =~ "/pkg/fdroid/#{handle}/_all?fingerprint="
195 + end
196 +
197 + test "the code encodes the same URL it prints", %{html: html, user: user} do
198 + {:ok, fingerprint} = GitGud.Packages.Formats.Fdroid.fingerprint(user)
199 +
200 + assert html =~ fingerprint
201 + end
202 + end
203 +
173 204 describe "the packages page" do
174 205 test "documents both facets without anything published yet", %{conn: conn, path: path} do
175 206 {:ok, view, html} = live(conn, path)

Parents: 316291c