diff --git a/README.md b/README.md index 5065a313f..19691b181 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@
website   •   - macOS app + macOS app   •   web view + cli   •   diff --git a/docs/src/app/(home)/marketing-button.tsx b/docs/src/app/(home)/marketing-button.tsx index b1b1038c7..ef5e0434a 100644 --- a/docs/src/app/(home)/marketing-button.tsx +++ b/docs/src/app/(home)/marketing-button.tsx @@ -183,6 +183,9 @@ export function MarketingButton({ const ArrowIcon = iconDirection === 'down' ? ArrowDown : ArrowRight; const ChevronsIcon = iconDirection === 'down' ? ChevronsDown : ChevronsRight; const isFileLink = typeof href === 'string' && href.split('?')[0]?.toLowerCase().endsWith('.pdf'); + const isRedirectRoute = + typeof href === 'string' && (href.startsWith('/download/') || href.startsWith('/updates/')); + const useRawAnchor = isFileLink || isRedirectRoute; const computedDownload = typeof download !== 'undefined' ? download : isFileLink ? '' : undefined; const buttonContent = ( @@ -251,7 +254,7 @@ export function MarketingButton({ const defaultRel = target === '_blank' ? 'noopener noreferrer' : undefined; const finalRel = rel || defaultRel; - if (isFileLink) { + if (useRawAnchor) { return ( ); })} - + Download @@ -186,7 +186,7 @@ export function SiteNav() { ); })} - + Download diff --git a/docs/src/app/continue/page.tsx b/docs/src/app/continue/page.tsx index b4f54a0a7..687280c77 100644 --- a/docs/src/app/continue/page.tsx +++ b/docs/src/app/continue/page.tsx @@ -9,7 +9,7 @@ import { PENDING_SHARE_COOKIE, PORT_PARAM, } from '@/lib/deferred-share'; -import { SPLASH_DOWNLOAD_URL } from '@/lib/share-splash'; +import { DOWNLOAD_ROUTE } from '@/lib/site'; export const dynamic = 'force-dynamic'; @@ -68,7 +68,7 @@ export default async function ContinuePage({ searchParams }: ContinuePageProps)
Don’t have the app yet?{' '}
Download it for macOS diff --git a/docs/src/app/d/[encoded]/download/route.test.ts b/docs/src/app/d/[encoded]/download/route.test.ts new file mode 100644 index 000000000..d8ecf95d8 --- /dev/null +++ b/docs/src/app/d/[encoded]/download/route.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, mock, test } from 'bun:test'; + +const SPLASH_URL = + 'https://github.com/inkeep/open-knowledge/releases/latest/download/OpenKnowledge-arm64.dmg'; + +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; +let _lastCapture: CaptureOpts | null = null; +mock.module('../../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'splash-1', +})); + +let _viewKind: 'ok' | 'invalid' | 'unsupported-version' = 'ok'; +mock.module('../../../../lib/share-splash.ts', () => ({ + buildSplashViewModel: () => ({ kind: _viewKind }), + SPLASH_DOWNLOAD_URL: SPLASH_URL, +})); + +mock.module('../../../../lib/deferred-share.ts', () => ({ + buildPendingShareCookie: (encoded: string) => ({ name: 'ok-pending-share', value: encoded }), +})); + +const { GET } = await import('./route.ts'); + +function call(encoded: string): Promise { + return GET(new Request(`https://openknowledge.ai/d/${encoded}/download`), { + params: Promise.resolve({ encoded }), + }); +} + +describe('GET /d/[encoded]/download', () => { + test('valid share: 302 to the DMG, sets the pairing cookie, counts share-splash', async () => { + _viewKind = 'ok'; + _lastCapture = null; + const res = await call('valid-share'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(SPLASH_URL); + expect(res.headers.get('set-cookie')).toContain('ok-pending-share=valid-share'); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.properties?.channel).toBe('stable'); + expect(_lastCapture?.properties?.source).toBe('share-splash'); + expect(_lastCapture?.distinctId).toBe('splash-1'); + }); + + test('invalid share: 302 with NO cookie but still counts the download', async () => { + _viewKind = 'invalid'; + _lastCapture = null; + const res = await call('bad-share'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(SPLASH_URL); + expect(res.headers.get('set-cookie')).toBeNull(); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.properties?.source).toBe('share-splash'); + }); + + test('unsupported-version share: no cookie, still counts', async () => { + _viewKind = 'unsupported-version'; + _lastCapture = null; + const res = await call('old-share'); + expect(res.status).toBe(302); + expect(res.headers.get('set-cookie')).toBeNull(); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + }); +}); diff --git a/docs/src/app/d/[encoded]/download/route.ts b/docs/src/app/d/[encoded]/download/route.ts index 671482f1e..2bb4fcc25 100644 --- a/docs/src/app/d/[encoded]/download/route.ts +++ b/docs/src/app/d/[encoded]/download/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from 'next/server'; import { buildPendingShareCookie } from '@/lib/deferred-share'; import { buildSplashViewModel, SPLASH_DOWNLOAD_URL } from '@/lib/share-splash'; +import { captureServerEvent, resolveDistinctId } from '@/lib/track'; export async function GET( - _request: Request, + request: Request, { params }: { params: Promise<{ encoded: string }> }, ): Promise { const { encoded } = await params; @@ -14,5 +15,11 @@ export async function GET( response.cookies.set(buildPendingShareCookie(encoded)); } + captureServerEvent({ + event: 'dmg_downloaded', + distinctId: resolveDistinctId(request), + properties: { channel: 'stable', source: 'share-splash' }, + }); + return response; } diff --git a/docs/src/app/d/[encoded]/splash-share-view.tsx b/docs/src/app/d/[encoded]/splash-share-view.tsx index 2ca01aa1a..bb5be8013 100644 --- a/docs/src/app/d/[encoded]/splash-share-view.tsx +++ b/docs/src/app/d/[encoded]/splash-share-view.tsx @@ -1,12 +1,8 @@ import { DotIcon, GitBranchIcon } from 'lucide-react'; import Link from 'next/link'; import { OkWordmark } from '@/components/ok-wordmark'; -import { - buildCloneCommand, - SPLASH_DOWNLOAD_URL, - SPLASH_INSTALL_COMMAND, - type SplashView, -} from '@/lib/share-splash'; +import { buildCloneCommand, SPLASH_INSTALL_COMMAND, type SplashView } from '@/lib/share-splash'; +import { DOWNLOAD_ROUTE } from '@/lib/site'; import { DotTexture } from '../../(home)/dot-texture'; import { SiteFooter } from '../../(home)/footer'; import { SplashButtonLabel, splashPrimaryButton } from './splash-buttons'; @@ -133,7 +129,7 @@ export function SplashFallback({ heading }: { heading: string }) {
- + DOWNLOAD FOR MAC diff --git a/docs/src/app/download/beta/route.test.ts b/docs/src/app/download/beta/route.test.ts index fc1b2bda7..8f357f7a8 100644 --- a/docs/src/app/download/beta/route.test.ts +++ b/docs/src/app/download/beta/route.test.ts @@ -23,30 +23,56 @@ mock.module('../../../lib/download-links.ts', () => ({ }), })); +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; +let _lastCapture: CaptureOpts | null = null; +mock.module('../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'visitor-9', + referrerHostname: () => undefined, +})); + const { GET } = await import('./route.ts'); +function call(): Promise { + return GET(new Request('https://openknowledge.ai/download/beta')); +} + describe('GET /download/beta', () => { - test('302 to the fresh beta URL with CDN-cacheable headers', async () => { + test('302 to the fresh beta URL and fires dmg_downloaded (beta)', async () => { _redirect = { kind: 'fresh', url: TEST_DMG_URL }; - const res = await GET(); + _lastCapture = null; + const res = await call(); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(TEST_DMG_URL); expect(res.headers.get('cache-control')).toBe(SUCCESS_CACHE_CONTROL); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.distinctId).toBe('visitor-9'); + expect(_lastCapture?.properties?.channel).toBe('beta'); }); - test('302 to the stale LKG URL with CDN-cacheable headers', async () => { + test('302 to the stale LKG URL and still counts', async () => { _redirect = { kind: 'stale-lkg', url: TEST_DMG_URL, refreshError: 'network down' }; - const res = await GET(); + _lastCapture = null; + const res = await call(); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(TEST_DMG_URL); expect(res.headers.get('cache-control')).toBe(SUCCESS_CACHE_CONTROL); + expect(_lastCapture?.properties?.channel).toBe('beta'); }); - test('302 to the releases page uncached on fallback', async () => { + test('302 to the releases page on fallback is NOT counted as a download', async () => { _redirect = { kind: 'fallback', url: RELEASES_PAGE_URL, cause: 'API error' }; - const res = await GET(); + _lastCapture = null; + const res = await call(); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(RELEASES_PAGE_URL); expect(res.headers.get('cache-control')).toBe(FALLBACK_CACHE_CONTROL); + expect(_lastCapture).toBeNull(); }); }); diff --git a/docs/src/app/download/beta/route.ts b/docs/src/app/download/beta/route.ts index 7d5b67819..79fd8a530 100644 --- a/docs/src/app/download/beta/route.ts +++ b/docs/src/app/download/beta/route.ts @@ -1,10 +1,11 @@ import { createBetaResolver, toRedirectResponse } from '@/lib/download-links'; +import { captureServerEvent, referrerHostname, resolveDistinctId } from '@/lib/track'; export const dynamic = 'force-dynamic'; const resolveBetaRedirect = createBetaResolver(); -export async function GET(): Promise { +export async function GET(request: Request): Promise { const redirect = await resolveBetaRedirect(); if (redirect.kind === 'stale-lkg') { console.warn( @@ -14,5 +15,12 @@ export async function GET(): Promise { if (redirect.kind === 'fallback') { console.error(`[download/beta] falling back to releases page: ${redirect.cause}`); } + if (redirect.kind !== 'fallback') { + captureServerEvent({ + event: 'dmg_downloaded', + distinctId: resolveDistinctId(request), + properties: { channel: 'beta', referrer: referrerHostname(request) }, + }); + } return toRedirectResponse(redirect); } diff --git a/docs/src/app/download/stable/route.test.ts b/docs/src/app/download/stable/route.test.ts index d286c801a..d4f49a6ae 100644 --- a/docs/src/app/download/stable/route.test.ts +++ b/docs/src/app/download/stable/route.test.ts @@ -1,12 +1,33 @@ -import { describe, expect, test } from 'bun:test'; -import { STABLE_CACHE_CONTROL, STABLE_DMG_URL } from '../../../lib/download-links.ts'; -import { GET } from './route.ts'; +import { describe, expect, mock, test } from 'bun:test'; +import { STABLE_DMG_URL } from '../../../lib/download-links.ts'; + +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; + +let _lastCapture: CaptureOpts | null = null; +mock.module('../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'visitor-1', + referrerHostname: () => 'news.ycombinator.com', +})); + +const { GET } = await import('./route.ts'); describe('GET /download/stable', () => { - test('302 to the stable DMG URL with CDN-cacheable headers', () => { - const res = GET(); + test('302 to the stable DMG URL, uncached, and fires dmg_downloaded', () => { + _lastCapture = null; + const res = GET(new Request('https://openknowledge.ai/download/stable')); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(STABLE_DMG_URL); - expect(res.headers.get('cache-control')).toBe(STABLE_CACHE_CONTROL); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.distinctId).toBe('visitor-1'); + expect(_lastCapture?.properties?.channel).toBe('stable'); + expect(_lastCapture?.properties?.referrer).toBe('news.ycombinator.com'); }); }); diff --git a/docs/src/app/download/stable/route.ts b/docs/src/app/download/stable/route.ts index 1a31829fd..5fd6ed0ab 100644 --- a/docs/src/app/download/stable/route.ts +++ b/docs/src/app/download/stable/route.ts @@ -1,13 +1,19 @@ -import { STABLE_CACHE_CONTROL, STABLE_DMG_URL } from '@/lib/download-links'; +import { STABLE_DMG_URL } from '@/lib/download-links'; +import { captureServerEvent, referrerHostname, resolveDistinctId } from '@/lib/track'; export const dynamic = 'force-dynamic'; -export function GET(): Response { +export function GET(request: Request): Response { + captureServerEvent({ + event: 'dmg_downloaded', + distinctId: resolveDistinctId(request), + properties: { channel: 'stable', referrer: referrerHostname(request) }, + }); return new Response(null, { status: 302, headers: { location: STABLE_DMG_URL, - 'cache-control': STABLE_CACHE_CONTROL, + 'cache-control': 'no-store', }, }); } diff --git a/docs/src/app/updates/[channel]/[...path]/route.test.ts b/docs/src/app/updates/[channel]/[...path]/route.test.ts new file mode 100644 index 000000000..4d76af538 --- /dev/null +++ b/docs/src/app/updates/[channel]/[...path]/route.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, mock, test } from 'bun:test'; + +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; +let _lastCapture: CaptureOpts | null = null; + +mock.module('../../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'updater-1', +})); + +const BETA_DMG_URL = + 'https://github.com/inkeep/open-knowledge/releases/download/v0.20.0-beta.4/OpenKnowledge-arm64.dmg'; +type BetaRedirect = { kind: string; url: string; cause?: string }; +let _betaRedirect: BetaRedirect = { kind: 'fresh', url: BETA_DMG_URL }; +mock.module('../../../../lib/download-links.ts', () => ({ + createBetaResolver: () => () => Promise.resolve(_betaRedirect), +})); + +const { GET } = await import('./route.ts'); +const REL = 'https://github.com/inkeep/open-knowledge/releases'; + +function call( + channel: string, + path: string[], + headers: Record = {}, +): Promise { + return GET( + new Request(`https://openknowledge.ai/updates/${channel}/${path.join('/')}`, { headers }), + { + params: Promise.resolve({ channel, path }), + }, + ); +} + +describe('GET /updates/[channel]/[...path]', () => { + test('stable manifest 302s to the latest alias and is NOT counted', async () => { + _lastCapture = null; + const res = await call('stable', ['latest-mac.yml']); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/latest/download/latest-mac.yml`); + expect(_lastCapture).toBeNull(); + }); + + test('beta manifest 302s to the resolved beta tag and is NOT counted', async () => { + _betaRedirect = { kind: 'fresh', url: BETA_DMG_URL }; + _lastCapture = null; + const res = await call('beta', ['beta-mac.yml']); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0-beta.4/beta-mac.yml`); + expect(_lastCapture).toBeNull(); + }); + + test('stable zip 302s to the tagged release and counts app_update_downloaded', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-arm64-mac.zip'; + const res = await call('stable', [file], { 'x-ok-from-version': '0.19.1' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(_lastCapture?.event).toBe('app_update_downloaded'); + expect(_lastCapture?.properties?.channel).toBe('stable'); + expect(_lastCapture?.properties?.artifact_type).toBe('zip'); + expect(_lastCapture?.properties?.to_version).toBe('0.20.0'); + expect(_lastCapture?.properties?.from_version).toBe('0.19.1'); + }); + + test('beta zip parses the prerelease version and counts (no from_version header)', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-beta.4-arm64-mac.zip'; + const res = await call('beta', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0-beta.4/${file}`); + expect(_lastCapture?.properties?.to_version).toBe('0.20.0-beta.4'); + expect(_lastCapture?.properties?.from_version).toBeUndefined(); + }); + + test('blockmap 302s but is NOT counted', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-arm64-mac.zip.blockmap'; + const res = await call('stable', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`); + expect(_lastCapture).toBeNull(); + }); + + test('human dmg 302s (latest alias) but is NOT counted', async () => { + _lastCapture = null; + const res = await call('stable', ['OpenKnowledge-arm64.dmg']); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/latest/download/OpenKnowledge-arm64.dmg`); + expect(_lastCapture).toBeNull(); + }); + + test('dmg blockmap 302s (latest alias) but is NOT counted', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-arm64.dmg.blockmap'; + const res = await call('stable', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/latest/download/${file}`); + expect(_lastCapture).toBeNull(); + }); + + test('invalid channel → 404', async () => { + expect((await call('canary', ['latest-mac.yml'])).status).toBe(404); + }); + + test('path traversal / multi-segment → 404', async () => { + expect((await call('stable', ['..', 'secret'])).status).toBe(404); + }); + + test('unknown artifact type → 404', async () => { + expect((await call('stable', ['random.txt'])).status).toBe(404); + }); + + test('x64 zip parses the version and counts', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-x64-mac.zip'; + const res = await call('stable', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`); + expect(_lastCapture?.properties?.to_version).toBe('0.20.0'); + }); + + test('beta manifest 503s (no-store) on resolver fallback, not counted', async () => { + _betaRedirect = { kind: 'fallback', url: REL, cause: 'API error' }; + _lastCapture = null; + const res = await call('beta', ['beta-mac.yml']); + expect(res.status).toBe(503); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(_lastCapture).toBeNull(); + _betaRedirect = { kind: 'fresh', url: BETA_DMG_URL }; + }); +}); diff --git a/docs/src/app/updates/[channel]/[...path]/route.ts b/docs/src/app/updates/[channel]/[...path]/route.ts new file mode 100644 index 000000000..d9758046a --- /dev/null +++ b/docs/src/app/updates/[channel]/[...path]/route.ts @@ -0,0 +1,88 @@ +import { createBetaResolver } from '@/lib/download-links'; +import { captureServerEvent, resolveDistinctId } from '@/lib/track'; + +export const dynamic = 'force-dynamic'; + +const RELEASES_BASE = 'https://github.com/inkeep/open-knowledge/releases'; +const VALID_CHANNELS = new Set(['stable', 'beta']); +const SAFE_FILENAME = /^[A-Za-z0-9._-]+$/; +const ARTIFACT_VERSION = + /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)-(?:arm64|x64|universal)-mac\.zip(?:\.blockmap)?$/; +const BETA_TAG_FROM_URL = /\/releases\/download\/([^/]+)\//; +const FROM_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; + +const resolveBeta = createBetaResolver(); + +type ArtifactType = 'manifest' | 'zip' | 'blockmap' | 'dmg' | 'other'; + +function classify(filename: string): ArtifactType { + if (filename.endsWith('-mac.yml')) return 'manifest'; + if (filename.endsWith('.blockmap')) return 'blockmap'; + if (filename.endsWith('.zip')) return 'zip'; + if (filename.endsWith('.dmg')) return 'dmg'; + return 'other'; +} + +function redirect302(location: string): Response { + return new Response(null, { status: 302, headers: { location, 'cache-control': 'no-store' } }); +} + +function errorResponse(status: number): Response { + return new Response(null, { status, headers: { 'cache-control': 'no-store' } }); +} + +async function latestBetaTag(): Promise { + const redirect = await resolveBeta(); + if (redirect.kind === 'stale-lkg') { + console.warn( + `[updates/beta] serving stale LKG tag after refresh failure: ${redirect.refreshError}`, + ); + } + if (redirect.kind === 'fallback') return null; + return BETA_TAG_FROM_URL.exec(redirect.url)?.[1] ?? null; +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ channel: string; path: string[] }> }, +): Promise { + const { channel, path } = await params; + if (!VALID_CHANNELS.has(channel)) return errorResponse(404); + + const filename = path.join('/'); + if (!SAFE_FILENAME.test(filename)) return errorResponse(404); + + const type = classify(filename); + if (type === 'other') return errorResponse(404); + + const version = ARTIFACT_VERSION.exec(filename)?.[1]; + + let target: string; + if (version) { + target = `${RELEASES_BASE}/download/v${version}/${filename}`; + } else if (channel === 'stable') { + target = `${RELEASES_BASE}/latest/download/${filename}`; + } else { + const tag = await latestBetaTag(); + if (!tag) { + return errorResponse(503); + } + target = `${RELEASES_BASE}/download/${tag}/${filename}`; + } + + if (type === 'zip' && version) { + const rawFrom = request.headers.get('x-ok-from-version'); + captureServerEvent({ + event: 'app_update_downloaded', + distinctId: resolveDistinctId(request), + properties: { + channel, + artifact_type: 'zip', + to_version: version, + from_version: rawFrom && FROM_VERSION.test(rawFrom) ? rawFrom : undefined, + }, + }); + } + + return redirect302(target); +} diff --git a/docs/src/components/download-button.tsx b/docs/src/components/download-button.tsx index ee5c0272a..d45b1d5f2 100644 --- a/docs/src/components/download-button.tsx +++ b/docs/src/components/download-button.tsx @@ -1,6 +1,5 @@ import { DownloadIcon } from 'lucide-react'; -import Link from 'next/link'; -import { STABLE_DMG_URL } from '@/lib/download-links'; +import { DOWNLOAD_ROUTE } from '@/lib/site'; type DownloadButtonProps = { href?: string; @@ -8,11 +7,11 @@ type DownloadButtonProps = { }; export function DownloadButton({ - href = STABLE_DMG_URL, + href = DOWNLOAD_ROUTE, label = 'DOWNLOAD FOR MAC', }: DownloadButtonProps) { return ( - {label}