Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/components/VoiceDownload.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
// Smart download control for WaveKat Voice.
//
// Shows ONE primary button for the visitor's platform, with the other
// platform(s) tucked behind an "other platforms" disclosure. The site is
// statically built (no server to sniff the OS), so this is progressive
// enhancement: the server renders Mac as the default primary, and a tiny
// inline script promotes Linux when it detects a Linux user-agent. With JS
// off, the native <details> still lets anyone reach the other build.
import { Apple, Package, ChevronDown } from '@lucide/astro';
import { getMacDownload, getLinuxDownload } from '../lib/voice-download';

interface Props {
/** 'hero' = elevated pill (default), 'cta' = flatter button for cards. */
variant?: 'hero' | 'cta';
}
const { variant = 'hero' } = Astro.props;

const platforms = [
{ key: 'mac', icon: Apple, conversion: 'download_mac', dl: await getMacDownload() },
{ key: 'linux', icon: Package, conversion: 'download_linux', dl: await getLinuxDownload() },
];

// Mac is the SSR default primary; the script swaps to Linux when detected.
const DEFAULT = 'mac';

// Display utility is toggled separately from the rest: an element gets EITHER
// `inline-flex` (shown) or `hidden`, never both — putting both on one element
// lets `inline-flex` win the `display` race and `hidden` silently does nothing.
const primaryCls =
variant === 'cta'
? 'items-center gap-2.5 rounded px-5 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90'
: 'group items-center gap-2.5 rounded-lg px-5 py-3 text-sm font-bold text-white shadow-lg shadow-[#ff6d00]/20 transition-all hover:shadow-xl hover:shadow-[#ff6d00]/30 hover:-translate-y-0.5';
---

<div class="inline-flex flex-wrap items-center gap-3" data-voice-download>
{/* Primary button — one per platform, only the active one is shown. */}
{platforms.map((p) => (
<a
href={p.dl.url}
data-conversion={p.conversion}
data-dl-primary={p.key}
class:list={[primaryCls, p.key === DEFAULT ? 'inline-flex' : 'hidden']}
style="background-color: #ff6d00"
>
<p.icon class="w-5 h-5" />
{p.dl.label}
</a>
))}

{/* Other platforms — native <details> so it works without JS. */}
<details class="group relative" data-dl-more>
<summary class="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white [&::-webkit-details-marker]:hidden">
other platforms
<ChevronDown class="h-3.5 w-3.5 transition-transform group-open:rotate-180" />
</summary>
<div class="absolute left-0 top-full z-20 mt-2 flex min-w-[15rem] flex-col gap-1 rounded-lg border border-gray-200 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-wk-surface">
{platforms.map((p) => (
<a
href={p.dl.url}
data-conversion={p.conversion}
data-dl-alt={p.key}
class:list={[
'items-center gap-2.5 rounded px-3 py-2 transition-colors hover:bg-gray-100 dark:hover:bg-white/5',
p.key === DEFAULT ? 'hidden' : 'inline-flex',
]}
>
<p.icon class="h-4 w-4 shrink-0" style="color: #ff6d00" />
<span class="flex flex-col">
<span class="text-sm font-bold text-gray-900 dark:text-white">{p.dl.label}</span>
<span class="whitespace-nowrap text-[11px] font-normal text-gray-500 dark:text-gray-400">{p.dl.arch} · {p.dl.size}</span>
</span>
</a>
))}
</div>
</details>
</div>

<script is:inline>
// Promote the visitor's platform to the primary button. Runs synchronously
// as the markup is parsed, so there's no flash of the wrong default. Only
// Linux changes anything; Mac/Windows/iOS/Android keep the Mac default
// (Mac is the only desktop build besides Linux).
(function () {
var ua = navigator.userAgent || '';
var plat = navigator.platform || '';
var isLinux = /Linux/i.test(plat + ' ' + ua) && !/Android/i.test(ua);
if (!isLinux) return;
// Show/hide by swapping the single display class, never stacking both.
function setShown(el, shown) {
el.classList.toggle('inline-flex', shown);
el.classList.toggle('hidden', !shown);
}
function resolve() {
document.querySelectorAll('[data-voice-download]').forEach(function (root) {
if (root.dataset.dlResolved) return;
root.dataset.dlResolved = '1';
root.querySelectorAll('[data-dl-primary]').forEach(function (el) {
setShown(el, el.dataset.dlPrimary === 'linux');
});
root.querySelectorAll('[data-dl-alt]').forEach(function (el) {
setShown(el, el.dataset.dlAlt !== 'linux');
});
});
// Show only the active platform's caption, matching the single button.
// These <p> have no display utility, so toggling `hidden` alone is safe.
// Caption lines can sit after this script in the page, so DOMContentLoaded
// catches them — buttons swap synchronously (no flicker).
document.querySelectorAll('[data-dl-meta]').forEach(function (el) {
el.classList.toggle('hidden', el.dataset.dlMeta !== 'linux');
});
}
resolve();
document.addEventListener('DOMContentLoaded', resolve);
})();
</script>
125 changes: 82 additions & 43 deletions src/lib/voice-download.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,123 @@
// WaveKat Voice — macOS download metadata, read live from the release feed.
// WaveKat Voice — download metadata, read live from the release feeds.
//
// The installers live on Cloudflare R2, served at https://dl.wavekat.com/voice/
// (the same origin the app polls for updates). Rather than hard-code a version
// that goes stale every release, we read the current one from the macOS release
// feed at BUILD TIME and derive the download link from it. Bump a release in
// wavekat-voice and the next site build picks it up automatically — nothing to
// edit here.
// (the same origin the app polls for updates). Rather than hard-code versions
// that go stale every release, we read the current ones from electron-builder's
// release feeds at BUILD TIME and derive the download links from them. Bump a
// release in wavekat-voice and the next site build picks it up automatically —
// nothing to edit here.
//
// `latest-mac.yml` looks like:
// version: 0.0.21
// macOS feed `latest-mac.yml`:
// version: 0.0.26
// files:
// - url: WaveKat Voice-0.0.21-arm64-mac.zip (the in-app update payload)
// size: 120567410
// - url: WaveKat Voice-0.0.21-arm64.dmg (the human download)
// size: 125294046
// We want the .dmg entry — the .zip is what the app uses to update itself.
// - url: WaveKat Voice-0.0.26-arm64-mac.zip (the in-app update payload)
// - url: WaveKat Voice-0.0.26-arm64.dmg (the human download) ← we want this
//
// macOS only for now — that's the only platform the site surfaces.
// Linux feed `latest-linux.yml`:
// version: 0.0.26
// files:
// - url: WaveKat Voice-0.0.26.AppImage (the in-app update payload)
// - url: WaveKat Voice-0.0.26.deb (the human download) ← we want this
//
// In both feeds the installer we surface is the file entry that carries a
// `url`, `sha512`, then `size` (the .dmg / .deb). The other entry is the
// self-update payload, which we ignore.

const FEED = 'https://dl.wavekat.com/voice/latest-mac.yml';
const DL_BASE = 'https://dl.wavekat.com/voice';

// Used only if the feed can't be reached during a build, so a network blip
// never breaks the site. Reflects the last known-good release.
const FALLBACK = {
version: '0.0.21',
fileName: 'WaveKat Voice-0.0.21-arm64.dmg',
sizeBytes: 125294046,
};

export interface MacDownload {
export interface Download {
/** Button label. */
label: string;
/** Human-friendly hardware requirement. */
/** Human-friendly hardware/OS requirement. */
arch: string;
/** Current version, e.g. "0.0.21". */
/** Current version, e.g. "0.0.26". */
version: string;
/** Human-friendly size, e.g. "120 MB". */
size: string;
/** Full download URL (spaces percent-encoded). */
url: string;
}

function mb(bytes: number): string {
return `${Math.round(bytes / 1024 / 1024)} MB`;
interface Platform {
/** electron-builder release feed for this platform. */
feed: string;
/** File extension of the human installer, e.g. "dmg" or "deb". */
ext: string;
label: string;
arch: string;
/** Last known-good release, used if the feed can't be reached at build. */
fallback: { version: string; fileName: string; sizeBytes: number };
}

// Memoize across pages so a single build does one fetch, not one per page.
let cache: Promise<MacDownload> | null = null;
const MAC: Platform = {
feed: `${DL_BASE}/latest-mac.yml`,
ext: 'dmg',
label: 'Download for Mac',
arch: 'Macs with Apple chip (M1 or newer)',
fallback: {
version: '0.0.26',
fileName: 'WaveKat Voice-0.0.26-arm64.dmg',
sizeBytes: 125353721,
},
};

const LINUX: Platform = {
feed: `${DL_BASE}/latest-linux.yml`,
ext: 'deb',
label: 'Download for Linux',
arch: 'Debian & Ubuntu (.deb, 64-bit)',
fallback: {
version: '0.0.26',
fileName: 'WaveKat Voice-0.0.26.deb',
sizeBytes: 105649108,
},
};

export function getMacDownload(): Promise<MacDownload> {
if (!cache) cache = load();
return cache;
function mb(bytes: number): string {
return `${Math.round(bytes / 1024 / 1024)} MB`;
}

async function load(): Promise<MacDownload> {
let { version, fileName, sizeBytes } = FALLBACK;
async function load(p: Platform): Promise<Download> {
let { version, fileName, sizeBytes } = p.fallback;

try {
const res = await fetch(FEED, { signal: AbortSignal.timeout(8000) });
const res = await fetch(p.feed, { signal: AbortSignal.timeout(8000) });
if (res.ok) {
const yml = await res.text();
const v = yml.match(/^version:\s*(.+)$/m);
if (v) version = v[1].trim();
// The .dmg file entry, plus the `size:` line that follows it.
const dmg = yml.match(/url:\s*(.+\.dmg)\s*\n\s*sha512:[^\n]*\n\s*size:\s*(\d+)/);
if (dmg) {
fileName = dmg[1].trim();
sizeBytes = parseInt(dmg[2], 10);
// The installer file entry (url → sha512 → size), e.g. the .dmg / .deb.
const re = new RegExp(`url:\\s*(.+\\.${p.ext})\\s*\\n\\s*sha512:[^\\n]*\\n\\s*size:\\s*(\\d+)`);
const m = yml.match(re);
if (m) {
fileName = m[1].trim();
sizeBytes = parseInt(m[2], 10);
}
}
} catch {
// Network unavailable at build time — fall back to the constants above.
}

return {
label: 'Download for Mac',
arch: 'Macs with Apple chip (M1 or newer)',
label: p.label,
arch: p.arch,
version,
size: mb(sizeBytes),
url: `${DL_BASE}/${encodeURIComponent(fileName)}`,
};
}

// Memoize across pages so a single build does one fetch per platform.
const cache = new Map<Platform, Promise<Download>>();

function get(p: Platform): Promise<Download> {
let c = cache.get(p);
if (!c) {
c = load(p);
cache.set(p, c);
}
return c;
}

export const getMacDownload = (): Promise<Download> => get(MAC);
export const getLinuxDownload = (): Promise<Download> => get(LINUX);
37 changes: 12 additions & 25 deletions src/pages/voice/download.astro
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
---
import Voice from '../../layouts/Voice.astro';
import { Apple, PhoneCall, Mic, FileText, ShieldCheck } from '@lucide/astro';
import { getMacDownload } from '../../lib/voice-download';
import { PhoneCall, Mic, FileText } from '@lucide/astro';
import VoiceDownload from '../../components/VoiceDownload.astro';
import { getMacDownload, getLinuxDownload } from '../../lib/voice-download';

const macDownload = await getMacDownload();
const linuxDownload = await getLinuxDownload();

const steps = [
{
n: '1',
title: 'Open the download',
body: 'Double-click the .dmg, then drag WaveKat Voice into your Applications folder.',
body: 'On a Mac, double-click the .dmg and drag WaveKat Voice into Applications. On Linux, open the .deb in your software installer, or install it from a terminal with apt or dpkg.',
},
{
n: '2',
Expand All @@ -32,7 +34,7 @@ const highlights = [

<Voice
title="Download WaveKat Voice"
description="Download WaveKat Voice for Mac — a phone app that records and writes down every call. Approved by Apple and free to try."
description="Download WaveKat Voice for Mac or Linux — a phone app that records and writes down every call. Free to try."
>
<!-- Hero / download -->
<section class="relative mb-16 pt-4">
Expand All @@ -58,27 +60,12 @@ const highlights = [
and written down for you. Connect your phone provider and you're live in a couple of minutes.
</p>

<a
href={macDownload.url}
data-conversion="download_mac"
class="group inline-flex items-center gap-2.5 rounded-lg px-5 py-3 text-sm font-bold text-white shadow-lg shadow-[#ff6d00]/20 transition-all hover:shadow-xl hover:shadow-[#ff6d00]/30 hover:-translate-y-0.5"
style="background-color: #ff6d00"
>
<Apple class="w-5 h-5" />
{macDownload.label}
</a>

<p class="text-xs text-gray-400 dark:text-gray-600 mt-4">
macOS 12+ · {macDownload.arch} · v{macDownload.version} · {macDownload.size}
</p>
<VoiceDownload />

<p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mt-4 max-w-xl">
<span class="inline-flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<ShieldCheck class="w-3.5 h-3.5" style="color: #00e676" />
Approved by Apple
</span>
— it opens with a click, no scary security warnings to get past.
</p>
<div class="mt-4 text-xs text-gray-400 dark:text-gray-600">
<p data-dl-meta="mac">macOS 12+ · {macDownload.arch} · v{macDownload.version} · {macDownload.size}</p>
<p data-dl-meta="linux" class="hidden">Linux · {linuxDownload.arch} · v{linuxDownload.version} · {linuxDownload.size}</p>
</div>
</section>

<!-- What you get -->
Expand Down Expand Up @@ -142,7 +129,7 @@ const highlights = [
<script is:inline>
// Fire the Google Ads download conversion (label shares the AW account in
// Base.astro). No-op if gtag isn't loaded.
document.querySelectorAll('a[data-conversion="download_mac"]').forEach((el) => {
document.querySelectorAll('a[data-conversion^="download_"]').forEach((el) => {
el.addEventListener('click', () => {
if (typeof gtag === 'function') {
gtag('event', 'conversion', { send_to: 'AW-18128547216' });
Expand Down
Loading
Loading