feat: link desktop downloads to official GitHub releases

This commit is contained in:
2026-11-09 12:28:39 -05:00
committed by John Winter Murphy
parent afd1c231db
commit 1aca8110ad
8 changed files with 205 additions and 3 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test:downloads": "node --experimental-strip-types --test tests/desktop-downloads.test.mjs"
},
"dependencies": {
"@such-software/smirk-dapp-api": "^0.3.0",
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
import { OFFICIAL_RELEASES, type DesktopRelease, type DesktopPlatform } from '@/lib/desktop-releases';
import { suggestDesktopPlatform, type SuggestedPlatform } from '@/lib/desktop-platform';
const platforms: { id: DesktopPlatform; label: string; format: string }[] = [
{ id: 'macos', label: 'macOS', format: 'DMG installer' },
{ id: 'windows', label: 'Windows', format: 'x64 installer' },
{ id: 'linux', label: 'Linux', format: 'x86_64 AppImage' },
];
export default function DownloadChoices({ release }: { release: DesktopRelease | null }) {
const [suggested, setSuggested] = useState<SuggestedPlatform>(null);
useEffect(() => { setSuggested(suggestDesktopPlatform(navigator.platform, navigator.userAgent, navigator.maxTouchPoints)); }, []);
return <>
{suggested === 'mobile' && <p className="mb-6 rounded-xl border border-zinc-700 p-4 text-zinc-300">These installers are for desktop computers. Open this page on your Mac, Windows PC, or Linux computer.</p>}
<div className="grid gap-4 md:grid-cols-3">
{platforms.map(platform => {
const href = release?.downloads[platform.id];
const preferred = suggested === platform.id;
return <section key={platform.id} className={`flex flex-col rounded-2xl border p-6 ${preferred ? 'border-[#fbeb0a] bg-zinc-900' : 'border-zinc-700 bg-zinc-950'}`}>
<div className="mb-4 min-h-6 text-xs text-[#fbeb0a]">{preferred ? 'Suggested for your device' : ''}</div>
<h2 className="text-2xl font-semibold">{platform.label}</h2>
<p className="mt-2 text-sm text-zinc-400">{platform.format}</p>
{href ? <a href={href} className="mt-6 rounded-xl bg-[#fbeb0a] px-4 py-3 text-center font-bold text-black hover:bg-[#d4c708] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white">Download for {platform.label}</a>
: <><p className="mt-4 text-sm text-zinc-400">Check the official release for an available installer.</p><a href={OFFICIAL_RELEASES} className="mt-4 rounded-xl border border-zinc-600 px-4 py-3 text-center hover:border-[#fbeb0a]">View releases</a></>}
</section>;
})}
</div>
<p className="mt-6 text-xs text-zinc-500">Your device suggestion stays in this browser. You can choose any platform.</p>
</>;
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { latestDesktopRelease } from '@/lib/desktop-release-server';
import { OFFICIAL_RELEASES } from '@/lib/desktop-releases';
import DownloadChoices from './DownloadChoices';
export const metadata: Metadata = { title: 'Download desktop', description: 'Get Smirk Wallet for macOS, Windows, or Linux from the official GitHub release.' };
export const revalidate = 300;
export default async function Downloads() {
const release = await latestDesktopRelease();
return <div className="mx-auto w-full max-w-5xl px-6 py-12 md:py-20">
<Link href="/" className="text-sm text-zinc-400 hover:text-[#fbeb0a]">← Smirk Wallet</Link>
<header className="mb-10 mt-10">
<p className="creepster-text text-2xl">Your wallet. Your computer.</p>
<h1 className="mt-3 text-4xl font-bold tracking-tight md:text-5xl">Download Smirk</h1>
<p className="mt-5 max-w-2xl text-zinc-300">Smirk Wallet for macOS, Windows, and Linux. Downloads come directly from our official GitHub releases.</p>
<p className="mt-3 text-sm text-zinc-400">{release ? `Latest published release: ${release.version}` : 'We could not load the latest release. You can still browse the official releases below.'}</p>
</header>
<DownloadChoices release={release} />
<div className="mt-10 flex flex-wrap gap-x-6 gap-y-3 text-sm">
<a href={release?.releaseUrl ?? OFFICIAL_RELEASES} className="text-[#fbeb0a] underline underline-offset-4">Release notes and all files</a>
<a href="https://chromewebstore.google.com/detail/smirk-wallet/mbohhckapcchfmanheggbkekofhajnci" className="text-zinc-300 underline underline-offset-4">Get the browser extension</a>
</div>
</div>;
}
+6 -2
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { OFFICIAL_RELEASES } from '@/lib/desktop-releases';
import { getChallenge, verifySignature, getMe, getUserCount, type AuthResponse } from '@/lib/api';
type Asset = 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
@@ -169,8 +170,9 @@ export default function Home() {
// Show loading while checking session
if (checkingSession) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="min-h-screen flex flex-col gap-6 items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
<Link href="/downloads" className="text-[#fbeb0a] underline underline-offset-4">Download desktop</Link>
</div>
);
}
@@ -243,6 +245,8 @@ export default function Home() {
)}
</div>
<Link href="/downloads" className="mb-8 rounded-xl border border-[#fbeb0a] px-6 py-3 font-semibold text-[#fbeb0a] hover:bg-zinc-900">Download desktop</Link>
{/* Initial state - Connect button */}
{step === 'initial' && (
<div className="flex flex-col items-center gap-6">
@@ -263,7 +267,7 @@ export default function Home() {
<span className="text-sm">Chrome Store</span>
</a>
<a
href="https://github.com/Such-Software/smirk-extension/releases"
href={OFFICIAL_RELEASES}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg
+10
View File
@@ -0,0 +1,10 @@
export type SuggestedPlatform = 'macos' | 'windows' | 'linux' | 'mobile' | null;
/** A local display hint only; it never changes the available downloads. */
export function suggestDesktopPlatform(platform: string, userAgent: string, touchPoints = 0): SuggestedPlatform {
if (/Android|iPhone|iPad|iPod/i.test(userAgent) || /Mac/i.test(platform) && touchPoints > 1) return 'mobile';
if (/Win/i.test(platform)) return 'windows';
if (/Mac/i.test(platform)) return 'macos';
if (/Linux/i.test(platform)) return 'linux';
return null;
}
+7
View File
@@ -0,0 +1,7 @@
import { unstable_cache } from 'next/cache';
import { fetchDesktopRelease } from './desktop-releases';
// Cache the bounded parsed result, including failures, for five minutes.
export const latestDesktopRelease = unstable_cache(
() => fetchDesktopRelease(), ['official-smirk-desktop-release-v1'], { revalidate: 300 },
);
+70
View File
@@ -0,0 +1,70 @@
export const OFFICIAL_RELEASES = 'https://github.com/Such-Software/smirk-monorepo/releases';
export const LATEST_RELEASE_API = 'https://api.github.com/repos/Such-Software/smirk-monorepo/releases/latest';
export const RELEASE_METADATA_MAX_BYTES = 512 * 1024;
export type DesktopPlatform = 'macos' | 'windows' | 'linux';
export interface DesktopRelease {
version: string;
releaseUrl: string;
downloads: Record<DesktopPlatform, string | null>;
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
/** Only the published installer names used by the official release are offered. */
export function parseDesktopRelease(input: unknown): DesktopRelease | null {
const release = record(input);
if (!release || release.draft !== false || release.prerelease !== false
|| typeof release.tag_name !== 'string' || !/^v(?:0|[1-9][0-9]{0,5})\.(?:0|[1-9][0-9]{0,5})\.(?:0|[1-9][0-9]{0,5})$/.test(release.tag_name)
|| release.html_url !== `${OFFICIAL_RELEASES}/tag/${release.tag_name}`
|| typeof release.published_at !== 'string' || !Number.isFinite(Date.parse(release.published_at))
|| !Array.isArray(release.assets) || release.assets.length > 100) return null;
const version = release.tag_name.slice(1);
const names: Record<DesktopPlatform, string> = {
macos: `Smirk-Wallet-${version}.dmg`,
linux: `Smirk.Wallet_${version}_amd64.AppImage`,
windows: `Smirk.Wallet_${version}_x64-setup.exe`,
};
const downloads = {} as DesktopRelease['downloads'];
for (const platform of ['macos', 'windows', 'linux'] as const) {
const matches = release.assets.map(record).filter(asset => asset?.name === names[platform]);
const asset = matches.length === 1 ? matches[0] : null;
const expected = `${OFFICIAL_RELEASES}/download/${release.tag_name}/${names[platform]}`;
downloads[platform] = asset?.state === 'uploaded' && Number.isSafeInteger(asset.size) && Number(asset.size) > 0
&& asset.browser_download_url === expected ? expected : null;
}
return { version, releaseUrl: release.html_url as string, downloads };
}
/** Metadata only. No authentication, visitor headers, redirects or artifact proxy. */
export async function fetchDesktopRelease(fetcher: typeof fetch = fetch): Promise<DesktopRelease | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const response = await fetcher(LATEST_RELEASE_API, {
method: 'GET', redirect: 'error', cache: 'no-store', signal: controller.signal,
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Smirk-Website-Downloads', 'X-GitHub-Api-Version': '2022-11-28' },
});
if (!response.ok || !response.body || !response.headers.get('content-type')?.toLowerCase().includes('application/json')) return null;
const length = response.headers.get('content-length');
if (length !== null && (!/^[0-9]+$/.test(length) || Number(length) > RELEASE_METADATA_MAX_BYTES)) return null;
reader = response.body.getReader();
const chunks: Uint8Array[] = []; let size = 0;
for (;;) {
const next = await reader.read();
if (next.done) break;
size += next.value.byteLength;
if (size > RELEASE_METADATA_MAX_BYTES) return null;
chunks.push(next.value);
}
const bytes = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return parseDesktopRelease(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
} catch { return null; }
finally {
controller.abort(); clearTimeout(timer);
if (reader) { void reader.cancel().catch(() => {}); reader.releaseLock(); }
}
}
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseDesktopRelease, fetchDesktopRelease, OFFICIAL_RELEASES, LATEST_RELEASE_API, RELEASE_METADATA_MAX_BYTES } from '../src/lib/desktop-releases.ts';
import { suggestDesktopPlatform } from '../src/lib/desktop-platform.ts';
const names = ['Smirk-Wallet-0.3.1.dmg', 'Smirk.Wallet_0.3.1_amd64.AppImage', 'Smirk.Wallet_0.3.1_x64-setup.exe'];
function release() { return { draft: false, prerelease: false, tag_name: 'v0.3.1', html_url: `${OFFICIAL_RELEASES}/tag/v0.3.1`, published_at: '2026-01-01T00:00:00Z', assets: names.map(name => ({name, state:'uploaded', size:1234, browser_download_url:`${OFFICIAL_RELEASES}/download/v0.3.1/${name}`})) }; }
function response(value) { return new Response(JSON.stringify(value), {headers:{'content-type':'application/json'}}); }
test('published official installers retain exact URLs and never invent a newer version', () => {
const result = parseDesktopRelease(release());
assert.equal(result.version, '0.3.1');
assert.equal(result.downloads.macos, release().assets[0].browser_download_url);
assert.equal(result.downloads.linux, release().assets[1].browser_download_url);
assert.equal(result.downloads.windows, release().assets[2].browser_download_url);
});
test('draft, prerelease, foreign repository, malformed tags and missing publication refuse', () => {
for (const patch of [{draft:true},{prerelease:true},{tag_name:'v0.3.2-rc1'},{html_url:'https://github.com/foreign/repo/releases/tag/v0.3.1'},{published_at:null}]) assert.equal(parseDesktopRelease({...release(),...patch}), null);
});
test('missing and ambiguous assets fall back per platform; unsigned archives are never installers', () => {
const value=release(); value.assets.push({...value.assets[0]});
assert.equal(parseDesktopRelease(value).downloads.macos, null);
assert.ok(parseDesktopRelease(value).downloads.windows);
value.assets = [{name:'Smirk-Wallet-0.3.1-unsigned.dmg',state:'uploaded',size:1234,browser_download_url:`${OFFICIAL_RELEASES}/download/v0.3.1/Smirk-Wallet-0.3.1-unsigned.dmg`}, {name:'macos.zip',state:'uploaded',size:1234,browser_download_url:`${OFFICIAL_RELEASES}/download/v0.3.1/macos.zip`}];
assert.deepEqual(parseDesktopRelease(value).downloads, {macos:null,windows:null,linux:null});
});
test('asset redirects, queries, other versions, foreign hosts, missing upload and invalid sizes refuse', () => {
for(const patch of [{browser_download_url:'https://example.com/download.dmg'},{browser_download_url:release().assets[0].browser_download_url+'?token=x'},{browser_download_url:release().assets[0].browser_download_url.replace('/v0.3.1/','/v0.3.2/')},{state:'new'},{size:0},{size:1.2}]) {
const value=release();Object.assign(value.assets[0],patch);assert.equal(parseDesktopRelease(value).downloads.macos,null);
}
});
test('metadata request is fixed, unauthenticated, bounded and does not forward visitor fields', async () => {
const result=await fetchDesktopRelease(async (url,options) => {
assert.equal(url,LATEST_RELEASE_API); assert.equal(options.method,'GET');assert.equal(options.redirect,'error');assert.equal(options.cache,'no-store');assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.headers.Authorization,undefined);assert.equal(options.headers.Cookie,undefined);assert.equal(options.headers['User-Agent'],'Smirk-Website-Downloads');
return response(release());
});assert.equal(result.version,'0.3.1');
});
test('network, rate limit, malformed JSON and oversized declared bodies return unavailable', async () => {
for (const fetcher of [async()=>{throw Error('offline')},async()=>new Response('',{status:429}),async()=>new Response('{',{headers:{'content-type':'application/json'}}),async()=>new Response('{}',{headers:{'content-type':'application/json','content-length':String(RELEASE_METADATA_MAX_BYTES+1)}})]) assert.equal(await fetchDesktopRelease(fetcher),null);
});
test('streaming size ceiling cancels an oversized body without trusting Content-Length', async () => {
let canceled=false;
const body=new ReadableStream({pull(controller){controller.enqueue(new Uint8Array(64*1024));},cancel(){canceled=true;}});
assert.equal(await fetchDesktopRelease(async()=>new Response(body,{headers:{'content-type':'application/json'}})),null);
assert.equal(canceled,true);
});
test('OS suggestion is local and mobile or unknown never assumes a desktop platform', () => {
assert.equal(suggestDesktopPlatform('MacIntel','desktop'), 'macos');assert.equal(suggestDesktopPlatform('Win32','desktop'),'windows');assert.equal(suggestDesktopPlatform('Linux x86_64','desktop'),'linux');
assert.equal(suggestDesktopPlatform('Linux arm','Android'),'mobile');assert.equal(suggestDesktopPlatform('MacIntel','iPad',5),'mobile');assert.equal(suggestDesktopPlatform('MacIntel','Safari',5),'mobile');assert.equal(suggestDesktopPlatform('unknown','unknown'),null);
});