feat(web): MobileAppDownload component on login + settings (#397 follow-up)

Closes the discoverability gap on the in-app update flow — the
/api/client/apk endpoint exists but had no web UI surface to find
it. User asked to be able to "visit the site on my phone and
download the apk from there."

- web/src/lib/components/MobileAppDownload.svelte — fetches
  /api/client/version once on mount; renders a download link with
  version + size when 200; renders nothing on 404 (no APK bundled,
  graceful degradation in dev environments and pre-CI-wiring images).
- Mounted on the login page (below the Register link) so the link
  is discoverable without authentication. The /api/client/* endpoints
  are themselves unauthed, so the flow works end-to-end for any
  visitor on a phone.
- Also mounted in Settings → Mobile app section for logged-in users
  who want to grab the matching APK for sideloading on a different
  device.

Browser handles the download via the server's existing
Content-Disposition: attachment; filename="minstrel.apk" header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 20:23:56 -04:00
parent f6e27cd39a
commit 2435ef7961
3 changed files with 68 additions and 0 deletions
@@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Smartphone } from 'lucide-svelte';
// Polls /api/client/version once on mount. Renders nothing if the
// server has no APK bundled (404) — graceful degradation in dev
// environments and pre-CI-wiring images.
//
// Mounted on the login page so the download is discoverable without
// authentication; the /api/client/* endpoints are themselves unauthed.
// Also reusable in Settings for logged-in users who want to grab the
// matching APK for sideloading on a different device.
type Info = { version: string; apkUrl: string; sizeBytes: number };
let info = $state<Info | null>(null);
function formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
onMount(async () => {
try {
const res = await fetch('/api/client/version');
if (!res.ok) return; // 404 = no bundled APK; stay hidden
const body = (await res.json()) as Partial<Info>;
if (!body.version || !body.apkUrl) return;
info = {
version: body.version,
apkUrl: body.apkUrl,
sizeBytes: body.sizeBytes ?? 0
};
} catch {
// Network error / non-JSON response — silent fail, no banner.
}
});
</script>
{#if info}
<a
href={info.apkUrl}
download="minstrel.apk"
class="mobile-app-download flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary no-underline transition-colors hover:bg-surface-hover hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
>
<Smartphone size={18} strokeWidth={1.5} class="shrink-0 text-accent" />
<span class="flex flex-col leading-tight">
<span class="font-medium">Get the Android app</span>
<span class="text-xs text-text-secondary">
{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''}
</span>
</span>
</a>
{/if}