From b3a19ced5eb36de71f87bce9c8e80adb1bd36307 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Tue, 14 Jul 2026 18:58:32 -0400 Subject: [PATCH 1/4] feat: add /download page with OS/arch detection and unsigned-build guidance Add a /download page that reads the latest openadapt-desktop GitHub Release assets via the Releases API, detects the visitor's OS/arch, and surfaces the right installer first (with an "all downloads" grid for every platform). - Graceful states: loading, ready, no-release-yet, and API-unreachable, each falling back to the GitHub releases page or running from source. - Honest "get past the OS warning" copy for macOS Gatekeeper (right-click -> Open) and Windows SmartScreen (More info -> Run anyway), calm and no-apology, matching the unsigned-for-v1 distribution decision. - Matches the repo's design tokens (bg-ground/text-ink/bg-panel/ border-hairline/text-accent/font-display); no em-dashes. - Adds a "Download" link to the primary nav. Build passes; prettier clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ --- components/NavHeader.js | 4 +- pages/download.js | 436 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 pages/download.js diff --git a/components/NavHeader.js b/components/NavHeader.js index 96a791cf..9599678e 100644 --- a/components/NavHeader.js +++ b/components/NavHeader.js @@ -17,6 +17,7 @@ const NAV_LINKS = [ { label: 'Safety', href: '/safety' }, { label: 'Compare', href: '/compare' }, { label: 'Pricing', href: '/#pricing' }, + { label: 'Download', href: '/download' }, { label: 'About', href: '/about' }, { label: 'Open source', @@ -104,7 +105,8 @@ export default function NavHeader() { rel="noopener noreferrer" onClick={() => { const ev = externalEvent(item.href) - if (ev) track(ev, { location: 'nav_mobile' }) + if (ev) + track(ev, { location: 'nav_mobile' }) }} > {item.label} diff --git a/pages/download.js b/pages/download.js new file mode 100644 index 00000000..b0293cd1 --- /dev/null +++ b/pages/download.js @@ -0,0 +1,436 @@ +import { useEffect, useMemo, useState } from 'react' +import Head from 'next/head' +import Link from 'next/link' + +import Footer from '@components/Footer' +import { track } from 'utils/analytics' + +const REPO = 'OpenAdaptAI/openadapt-desktop' +const RELEASES_API = `https://api.github.com/repos/${REPO}/releases/latest` +const RELEASES_PAGE = `https://github.com/${REPO}/releases` + +// Platform definitions. `match` classifies a release asset by filename so the +// page stays correct no matter how the CI names its artifacts, as long as the +// extension and arch token are present. +const PLATFORMS = [ + { + id: 'windows', + label: 'Windows', + arch: 'x64', + hint: '.msi installer (or .exe)', + match: (name) => + /\.msi$/i.test(name) || + (/\.exe$/i.test(name) && /setup/i.test(name)), + // Prefer the .msi over the NSIS .exe when both are present. + rank: (name) => (/\.msi$/i.test(name) ? 0 : 1), + }, + { + id: 'macos-arm', + label: 'macOS', + arch: 'Apple Silicon', + hint: '.dmg for M1 and later', + match: (name) => /\.dmg$/i.test(name) && /(aarch64|arm64)/i.test(name), + rank: () => 0, + }, + { + id: 'macos-x64', + label: 'macOS', + arch: 'Intel', + hint: '.dmg for Intel Macs', + match: (name) => + /\.dmg$/i.test(name) && /(x64|x86_64|intel)/i.test(name), + rank: () => 0, + }, + { + id: 'linux', + label: 'Linux', + arch: 'x64', + hint: '.AppImage (or .deb)', + match: (name) => /\.appimage$/i.test(name) || /\.deb$/i.test(name), + rank: (name) => (/\.appimage$/i.test(name) ? 0 : 1), + }, +] + +// Best-effort OS + arch detection so we can surface the right build first. +// Returns a platform id, or null when we cannot tell. +function detectPlatformId() { + if (typeof navigator === 'undefined') return null + const ua = (navigator.userAgent || '').toLowerCase() + const platform = (navigator.platform || '').toLowerCase() + + if (/win/.test(ua) || /win/.test(platform)) return 'windows' + if (/linux/.test(ua) && !/android/.test(ua)) return 'linux' + if (/mac/.test(ua) || /mac/.test(platform)) { + // On Apple Silicon, WebGL/renderer and touch heuristics are unreliable; + // default to Apple Silicon (the common case) and let the user pick + // Intel from the full list if needed. + return 'macos-arm' + } + return null +} + +// Given a release's assets and a platform, pick the best matching download. +function assetForPlatform(assets, platform) { + const candidates = assets + .filter((a) => platform.match(a.name)) + .sort((a, b) => platform.rank(a.name) - platform.rank(b.name)) + return candidates[0] || null +} + +function formatSize(bytes) { + if (!bytes && bytes !== 0) return '' + const mb = bytes / (1024 * 1024) + return `${mb.toFixed(1)} MB` +} + +export default function DownloadPage() { + const [status, setStatus] = useState('loading') // loading | ready | none | error + const [release, setRelease] = useState(null) + const [detectedId, setDetectedId] = useState(null) + + useEffect(() => { + setDetectedId(detectPlatformId()) + + let cancelled = false + async function load() { + try { + const res = await fetch(RELEASES_API, { + headers: { Accept: 'application/vnd.github+json' }, + }) + if (cancelled) return + if (res.status === 404) { + setStatus('none') + return + } + if (!res.ok) { + setStatus('error') + return + } + const data = await res.json() + setRelease(data) + const hasAssets = + Array.isArray(data.assets) && data.assets.length > 0 + setStatus(hasAssets ? 'ready' : 'none') + } catch (err) { + if (!cancelled) setStatus('error') + } + } + load() + return () => { + cancelled = true + } + }, []) + + const assets = + release && Array.isArray(release.assets) ? release.assets : [] + + // Resolve a download for each platform card. + const platformDownloads = useMemo( + () => + PLATFORMS.map((p) => ({ + platform: p, + asset: assetForPlatform(assets, p), + })), + [assets] + ) + + const recommended = useMemo( + () => + platformDownloads.find( + (d) => d.platform.id === detectedId && d.asset + ) || null, + [platformDownloads, detectedId] + ) + + const version = release ? release.tag_name || release.name : null + + return ( +
+ + Download OpenAdapt Desktop | OpenAdapt + + + + + + + + {/* Hero */} +
+

Download

+

+ Get OpenAdapt Desktop +

+

+ Record a workflow once. OpenAdapt compiles it into a + self-healing automation that replays on your own machine. + Healthy runs make no cloud model calls, and your recordings + stay local. Open source, MIT licensed. +

+ + {/* Recommended download */} +
+ {status === 'loading' && ( +

+ Finding the latest release... +

+ )} + + {status === 'ready' && recommended && ( +
+

Recommended for you

+

+ {recommended.platform.label} + {recommended.platform.arch + ? ` (${recommended.platform.arch})` + : ''} +

+

+ {recommended.platform.hint} + {recommended.asset.size + ? ` · ${formatSize(recommended.asset.size)}` + : ''} +

+ + {version && ( +

+ Latest release {version} +

+ )} +
+ )} + + {status === 'ready' && !recommended && ( +

+ We could not detect your operating system + automatically. Pick your platform below. +

+ )} + + {status === 'none' && ( +
+

+ Installers are on the way +

+

+ The first public build has not been published + yet. In the meantime you can run OpenAdapt from + source, and you can watch the releases page to + be notified when installers land. +

+ +
+ )} + + {status === 'error' && ( +
+

+ We could not reach GitHub just now +

+

+ The download list is served from GitHub + Releases. You can open the releases page + directly to grab the right installer. +

+ +
+ )} +
+
+ + {/* All platforms */} + {status === 'ready' && ( +
+

+ All downloads +

+

+ Every installer for the latest release + {version ? ` (${version})` : ''}. Choose the one that + matches your machine. +

+
+ {platformDownloads.map(({ platform, asset }) => ( +
+

+ {platform.label} + {platform.arch ? ` (${platform.arch})` : ''} +

+

+ {platform.hint} + {asset && asset.size + ? ` · ${formatSize(asset.size)}` + : ''} +

+ +
+ ))} +
+

+ Looking for a different build or an older version?{' '} + + Browse all releases on GitHub + + . +

+
+ )} + + {/* Get past the OS warning */} +
+
+

+ First time you open it +

+

+ These builds are not code-signed yet, so your operating + system will show a one-time warning before the first + launch. Here is exactly how to get past it. +

+ +
+
+

+ macOS +

+

+ macOS will say the app is from an unidentified + developer. To open it the first time: + right-click (or Control-click) OpenAdapt in + Applications, choose Open, then Open again. + macOS remembers your choice, so you will not see + this again. We are rolling out Apple + notarization shortly. +

+
+
+

+ Windows +

+

+ Windows SmartScreen may show a blue + "Windows protected your PC" banner. + Click More info, then Run anyway to install. + This appears because the installer is not + code-signed yet. Signing is on our roadmap. +

+
+
+

+ Both warnings are expected for unsigned software and do + not indicate a problem with the download. +

+
+
+ + {/* CTA */} +
+
+

+ Want it running on a real workflow? +

+

+ Bring the workflow you most want to automate. Fifteen + minutes, one workflow, on your own machine. +

+
+ + Book a demo + + + Read the docs + +
+
+
+ +
+
+ ) +} From 26c2209dc88587a5b057f818a7479745a0b0c641 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 16 Jul 2026 01:21:26 -0400 Subject: [PATCH 2/4] fix: bind desktop downloads to Experimental releases --- cypress/e2e/download.cy.js | 83 +++++++++++ pages/download.js | 282 +++++++++++++++++-------------------- utils/desktopRelease.js | 148 +++++++++++++++++++ 3 files changed, 358 insertions(+), 155 deletions(-) create mode 100644 cypress/e2e/download.cy.js create mode 100644 utils/desktopRelease.js diff --git a/cypress/e2e/download.cy.js b/cypress/e2e/download.cy.js new file mode 100644 index 00000000..6e40ca67 --- /dev/null +++ b/cypress/e2e/download.cy.js @@ -0,0 +1,83 @@ +const RELEASES_API = + 'https://api.github.com/repos/OpenAdaptAI/openadapt-desktop/releases?per_page=20' + +function asset(name, size = 1048576) { + return { + name, + size, + browser_download_url: `https://downloads.example/${name}`, + } +} + +function experimentalRelease(overrides = {}) { + const prefix = 'OpenAdapt-Desktop-Experimental-v0.1.0' + return { + draft: false, + prerelease: true, + tag_name: 'desktop-v0.1.0', + assets: [ + asset(`${prefix}-macos-arm64-adhoc.dmg`), + asset(`${prefix}-macos-x86_64-adhoc.dmg`), + asset(`${prefix}-windows-x86_64-unsigned.msi`), + asset(`${prefix}-windows-x86_64-unsigned-nsis-setup.exe`), + asset(`${prefix}-linux-x86_64-unsigned.AppImage`), + asset(`${prefix}-linux-x86_64-unsigned.deb`), + asset('SHA256SUMS', 512), + ], + ...overrides, + } +} + +describe('desktop downloads', () => { + it('does not imply installers exist before a complete prerelease is public', () => { + cy.intercept('GET', RELEASES_API, { + body: [ + { + draft: false, + prerelease: false, + tag_name: 'v0.3.2', + assets: [asset('openadapt_desktop-0.3.2-py3-none-any.whl')], + }, + ], + }) + cy.visit('/download') + + cy.contains('Experimental desktop') + cy.contains('No public desktop installer yet') + cy.contains('Install the working CLI') + .should('have.attr', 'href') + .and('equal', 'https://docs.openadapt.ai') + cy.contains('All downloads').should('not.exist') + cy.contains('First launch').should('not.exist') + }) + + it('selects a complete desktop-v Experimental prerelease and exact assets', () => { + const incomplete = experimentalRelease({ + tag_name: 'desktop-v0.2.0', + assets: [asset('unrelated.msi')], + }) + cy.intercept('GET', RELEASES_API, { + body: [incomplete, experimentalRelease()], + }) + cy.visit('/download') + + cy.contains('Experimental prerelease desktop-v0.1.0') + cy.contains('All downloads') + cy.contains('Windows (x64)') + .parents('.rounded-xl') + .contains('Download') + .should( + 'have.attr', + 'href', + 'https://downloads.example/OpenAdapt-Desktop-Experimental-v0.1.0-windows-x86_64-unsigned.msi' + ) + cy.contains('macOS (Apple Silicon)') + cy.contains('macOS (Intel)') + cy.contains('Linux (x64)') + cy.contains('The DMGs are ad-hoc signed') + cy.contains('The Windows installers are not yet Authenticode signed') + cy.contains('Download SHA256SUMS') + .should('have.attr', 'href') + .and('equal', 'https://downloads.example/SHA256SUMS') + }) +}) diff --git a/pages/download.js b/pages/download.js index b0293cd1..de02c60c 100644 --- a/pages/download.js +++ b/pages/download.js @@ -4,78 +4,15 @@ import Link from 'next/link' import Footer from '@components/Footer' import { track } from 'utils/analytics' - -const REPO = 'OpenAdaptAI/openadapt-desktop' -const RELEASES_API = `https://api.github.com/repos/${REPO}/releases/latest` -const RELEASES_PAGE = `https://github.com/${REPO}/releases` - -// Platform definitions. `match` classifies a release asset by filename so the -// page stays correct no matter how the CI names its artifacts, as long as the -// extension and arch token are present. -const PLATFORMS = [ - { - id: 'windows', - label: 'Windows', - arch: 'x64', - hint: '.msi installer (or .exe)', - match: (name) => - /\.msi$/i.test(name) || - (/\.exe$/i.test(name) && /setup/i.test(name)), - // Prefer the .msi over the NSIS .exe when both are present. - rank: (name) => (/\.msi$/i.test(name) ? 0 : 1), - }, - { - id: 'macos-arm', - label: 'macOS', - arch: 'Apple Silicon', - hint: '.dmg for M1 and later', - match: (name) => /\.dmg$/i.test(name) && /(aarch64|arm64)/i.test(name), - rank: () => 0, - }, - { - id: 'macos-x64', - label: 'macOS', - arch: 'Intel', - hint: '.dmg for Intel Macs', - match: (name) => - /\.dmg$/i.test(name) && /(x64|x86_64|intel)/i.test(name), - rank: () => 0, - }, - { - id: 'linux', - label: 'Linux', - arch: 'x64', - hint: '.AppImage (or .deb)', - match: (name) => /\.appimage$/i.test(name) || /\.deb$/i.test(name), - rank: (name) => (/\.appimage$/i.test(name) ? 0 : 1), - }, -] - -// Best-effort OS + arch detection so we can surface the right build first. -// Returns a platform id, or null when we cannot tell. -function detectPlatformId() { - if (typeof navigator === 'undefined') return null - const ua = (navigator.userAgent || '').toLowerCase() - const platform = (navigator.platform || '').toLowerCase() - - if (/win/.test(ua) || /win/.test(platform)) return 'windows' - if (/linux/.test(ua) && !/android/.test(ua)) return 'linux' - if (/mac/.test(ua) || /mac/.test(platform)) { - // On Apple Silicon, WebGL/renderer and touch heuristics are unreliable; - // default to Apple Silicon (the common case) and let the user pick - // Intel from the full list if needed. - return 'macos-arm' - } - return null -} - -// Given a release's assets and a platform, pick the best matching download. -function assetForPlatform(assets, platform) { - const candidates = assets - .filter((a) => platform.match(a.name)) - .sort((a, b) => platform.rank(a.name) - platform.rank(b.name)) - return candidates[0] || null -} +import { + assetForPlatform, + DESKTOP_PLATFORMS, + DESKTOP_RELEASES_API, + DESKTOP_RELEASES_PAGE, + detectDesktopPlatform, + releaseSigningState, + selectExperimentalDesktopRelease, +} from 'utils/desktopRelease' function formatSize(bytes) { if (!bytes && bytes !== 0) return '' @@ -89,28 +26,27 @@ export default function DownloadPage() { const [detectedId, setDetectedId] = useState(null) useEffect(() => { - setDetectedId(detectPlatformId()) + setDetectedId( + detectDesktopPlatform( + typeof navigator === 'undefined' ? null : navigator + ) + ) let cancelled = false async function load() { try { - const res = await fetch(RELEASES_API, { + const res = await fetch(DESKTOP_RELEASES_API, { headers: { Accept: 'application/vnd.github+json' }, }) if (cancelled) return - if (res.status === 404) { - setStatus('none') - return - } if (!res.ok) { setStatus('error') return } const data = await res.json() - setRelease(data) - const hasAssets = - Array.isArray(data.assets) && data.assets.length > 0 - setStatus(hasAssets ? 'ready' : 'none') + const selected = selectExperimentalDesktopRelease(data) + setRelease(selected) + setStatus(selected ? 'ready' : 'none') } catch (err) { if (!cancelled) setStatus('error') } @@ -127,7 +63,7 @@ export default function DownloadPage() { // Resolve a download for each platform card. const platformDownloads = useMemo( () => - PLATFORMS.map((p) => ({ + DESKTOP_PLATFORMS.map((p) => ({ platform: p, asset: assetForPlatform(assets, p), })), @@ -143,6 +79,8 @@ export default function DownloadPage() { ) const version = release ? release.tag_name || release.name : null + const signing = useMemo(() => releaseSigningState(assets), [assets]) + const checksumAsset = assets.find((asset) => asset.name === 'SHA256SUMS') return (
@@ -150,7 +88,7 @@ export default function DownloadPage() { Download OpenAdapt Desktop | OpenAdapt -

Download

+

Experimental desktop

- Get OpenAdapt Desktop + OpenAdapt Desktop preview

- Record a workflow once. OpenAdapt compiles it into a - self-healing automation that replays on your own machine. - Healthy runs make no cloud model calls, and your recordings - stay local. Open source, MIT licensed. + These installers validate native packaging and removal. The + desktop shell is not yet connected to workflow recording or + replay. Use the open-source{' '} + + openadapt-flow CLI + {' '} + for the working record, compile, certify, replay, and repair + path.

{/* Recommended download */} @@ -222,33 +169,39 @@ export default function DownloadPage() {
{version && (

- Latest release {version} + Experimental prerelease {version}

)} )} {status === 'ready' && !recommended && ( -

- We could not detect your operating system - automatically. Pick your platform below. -

+
+

+ Experimental prerelease {version} +

+

+ We could not safely choose an installer for this + device. Pick your platform and architecture + below. +

+
)} {status === 'none' && (

- Installers are on the way + No public desktop installer yet

- The first public build has not been published - yet. In the meantime you can run OpenAdapt from - source, and you can watch the releases page to - be notified when installers land. + No complete Experimental desktop prerelease has + been published. Use openadapt-flow today, or + watch the desktop releases page for the first + native preview.

- Run from source + Install the working CLI
@@ -275,11 +228,11 @@ export default function DownloadPage() {

The download list is served from GitHub Releases. You can open the releases page - directly to grab the right installer. + directly to inspect the published builds.

- Every installer for the latest release + Every installer for the latest Experimental prerelease {version ? ` (${version})` : ''}. Choose the one that matches your machine.

@@ -323,7 +276,7 @@ export default function DownloadPage() { {asset ? (
track('download_click', { platform: platform.id, @@ -346,7 +299,7 @@ export default function DownloadPage() {

Looking for a different build or an older version?{' '} @@ -357,62 +310,81 @@ export default function DownloadPage() {

)} - {/* Get past the OS warning */} -
-
-

- First time you open it -

-

- These builds are not code-signed yet, so your operating - system will show a one-time warning before the first - launch. Here is exactly how to get past it. -

- -
-
-

- macOS -

-

- macOS will say the app is from an unidentified - developer. To open it the first time: - right-click (or Control-click) OpenAdapt in - Applications, choose Open, then Open again. - macOS remembers your choice, so you will not see - this again. We are rolling out Apple - notarization shortly. -

-
-
-

- Windows -

-

- Windows SmartScreen may show a blue - "Windows protected your PC" banner. - Click More info, then Run anyway to install. - This appears because the installer is not - code-signed yet. Signing is on our roadmap. + {/* Guidance is tied to the signing labels in the published assets. */} + {status === 'ready' && + (!signing.macosNotarized || !signing.windowsSigned) && ( +

+
+

+ First launch +

+

+ Current Experimental builds may trigger an + operating-system publisher warning. Verify the + release checksum before overriding it.

+ +
+ {!signing.macosNotarized && ( +
+

+ macOS +

+

+ The DMGs are ad-hoc signed, not yet + Developer ID notarized. In Finder, + Control-click OpenAdapt Desktop, + choose Open, then confirm Open. Only + override Gatekeeper after verifying + the release checksum. +

+
+ )} + {!signing.windowsSigned && ( +
+

+ Windows +

+

+ The Windows installers are not yet + Authenticode signed. If SmartScreen + shows "Windows protected your + PC," verify the release checksum, + then choose More info and Run + anyway. +

+
+ )} +
+ {checksumAsset && ( +

+ + Download SHA256SUMS + {' '} + and verify GitHub build provenance from the + release before installing. +

+ )}
-

- Both warnings are expected for unsigned software and do - not indicate a problem with the download. -

-
-
+ )} {/* CTA */}

- Want it running on a real workflow? + Need the working workflow engine?

- Bring the workflow you most want to automate. Fifteen - minutes, one workflow, on your own machine. + Start with the openadapt-flow CLI, or bring the workflow + you most want to automate to a 30-minute call.

diff --git a/utils/desktopRelease.js b/utils/desktopRelease.js new file mode 100644 index 00000000..a96475bd --- /dev/null +++ b/utils/desktopRelease.js @@ -0,0 +1,148 @@ +export const DESKTOP_REPO = 'OpenAdaptAI/openadapt-desktop' +export const DESKTOP_RELEASES_API = `https://api.github.com/repos/${DESKTOP_REPO}/releases?per_page=20` +export const DESKTOP_RELEASES_PAGE = `https://github.com/${DESKTOP_REPO}/releases` + +const EXPERIMENTAL_TAG = /^desktop-v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ +const EXPERIMENTAL_PREFIX = /^OpenAdapt-Desktop-Experimental-/i + +export const DESKTOP_PLATFORMS = [ + { + id: 'windows', + label: 'Windows', + arch: 'x64', + hint: '.msi installer (or .exe)', + match: (name) => + EXPERIMENTAL_PREFIX.test(name) && + /-windows-x86_64-(?:(?:unsigned|authenticode)\.msi|(?:unsigned|authenticode)-nsis-setup\.exe)$/i.test( + name + ), + rank: (name) => (/\.msi$/i.test(name) ? 0 : 1), + }, + { + id: 'macos-arm', + label: 'macOS', + arch: 'Apple Silicon', + hint: '.dmg for M1 and later', + match: (name) => + EXPERIMENTAL_PREFIX.test(name) && + /-macos-arm64-(?:adhoc|developer-id-notarized)\.dmg$/i.test(name), + rank: () => 0, + }, + { + id: 'macos-x64', + label: 'macOS', + arch: 'Intel', + hint: '.dmg for Intel Macs', + match: (name) => + EXPERIMENTAL_PREFIX.test(name) && + /-macos-x86_64-(?:adhoc|developer-id-notarized)\.dmg$/i.test( + name + ), + rank: () => 0, + }, + { + id: 'linux', + label: 'Linux', + arch: 'x64', + hint: '.AppImage (or .deb)', + match: (name) => + EXPERIMENTAL_PREFIX.test(name) && + /-linux-x86_64-unsigned\.(?:AppImage|deb)$/i.test(name), + rank: (name) => (/\.AppImage$/i.test(name) ? 0 : 1), + }, +] + +const REQUIRED_ASSETS = [ + /-macos-arm64-(?:adhoc|developer-id-notarized)\.dmg$/i, + /-macos-x86_64-(?:adhoc|developer-id-notarized)\.dmg$/i, + /-windows-x86_64-(?:unsigned|authenticode)\.msi$/i, + /-windows-x86_64-(?:unsigned|authenticode)-nsis-setup\.exe$/i, + /-linux-x86_64-unsigned\.AppImage$/i, + /-linux-x86_64-unsigned\.deb$/i, +] + +function hasDownloadUrl(asset) { + return Boolean( + asset && + typeof asset.name === 'string' && + typeof asset.browser_download_url === 'string' && + asset.browser_download_url.startsWith('https://') + ) +} + +export function assetForPlatform(assets, platform) { + return assets + .filter(hasDownloadUrl) + .filter((asset) => platform.match(asset.name)) + .sort((a, b) => platform.rank(a.name) - platform.rank(b.name))[0] +} + +export function isCompleteExperimentalDesktopRelease(release) { + if ( + !release || + release.draft || + release.prerelease !== true || + !EXPERIMENTAL_TAG.test(release.tag_name || '') || + !Array.isArray(release.assets) + ) { + return false + } + + const assets = release.assets.filter(hasDownloadUrl) + const hasChecksums = assets.some((asset) => asset.name === 'SHA256SUMS') + return ( + hasChecksums && + REQUIRED_ASSETS.every((pattern) => + assets.some( + (asset) => + EXPERIMENTAL_PREFIX.test(asset.name) && + pattern.test(asset.name) + ) + ) + ) +} + +export function selectExperimentalDesktopRelease(releases) { + if (!Array.isArray(releases)) return null + return releases.find(isCompleteExperimentalDesktopRelease) || null +} + +export function detectDesktopPlatform(navigatorValue) { + if (!navigatorValue) return null + const fingerprint = `${navigatorValue.userAgent || ''} ${ + navigatorValue.platform || '' + }`.toLowerCase() + + // Browser signals do not reliably distinguish Apple Silicon from Intel. + // A wrong installer is worse than asking macOS visitors to choose. + if (/mac|iphone|ipad/.test(fingerprint)) return null + + const isArm = /(?:^|[^a-z])(?:arm64|aarch64|armv8)(?:[^a-z]|$)/.test( + fingerprint + ) + const isX64 = /x86_64|x86-64|amd64|win64|wow64|x64/.test(fingerprint) + if (isArm || !isX64) return null + if (/win/.test(fingerprint)) return 'windows' + if (/linux/.test(fingerprint) && !/android/.test(fingerprint)) return 'linux' + return null +} + +export function releaseSigningState(assets) { + const names = Array.isArray(assets) + ? assets.map((asset) => asset.name || '') + : [] + return { + macosNotarized: ['arm64', 'x86_64'].every((architecture) => + names.some((name) => + new RegExp( + `-macos-${architecture}-developer-id-notarized\\.dmg$`, + 'i' + ).test(name) + ) + ), + windowsSigned: [ + /-windows-x86_64-authenticode\.msi$/i, + /-windows-x86_64-authenticode-nsis-setup\.exe$/i, + ].every((pattern) => names.some((name) => pattern.test(name))), + } +} From 03cf67cacf2b6e2906c828556f1c267c069c0541 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 16 Jul 2026 01:26:57 -0400 Subject: [PATCH 3/4] test: run desktop release contract without a server --- cypress/e2e/download.cy.js | 102 +++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/cypress/e2e/download.cy.js b/cypress/e2e/download.cy.js index 6e40ca67..a9569338 100644 --- a/cypress/e2e/download.cy.js +++ b/cypress/e2e/download.cy.js @@ -1,5 +1,10 @@ -const RELEASES_API = - 'https://api.github.com/repos/OpenAdaptAI/openadapt-desktop/releases?per_page=20' +import { + assetForPlatform, + DESKTOP_PLATFORMS, + detectDesktopPlatform, + releaseSigningState, + selectExperimentalDesktopRelease, +} from '../../utils/desktopRelease' function asset(name, size = 1048576) { return { @@ -28,56 +33,67 @@ function experimentalRelease(overrides = {}) { } } -describe('desktop downloads', () => { - it('does not imply installers exist before a complete prerelease is public', () => { - cy.intercept('GET', RELEASES_API, { - body: [ - { - draft: false, - prerelease: false, - tag_name: 'v0.3.2', - assets: [asset('openadapt_desktop-0.3.2-py3-none-any.whl')], - }, - ], +describe('desktop release contract', () => { + it('ignores legacy, draft, and incomplete releases', () => { + const legacy = { + draft: false, + prerelease: false, + tag_name: 'v0.3.2', + assets: [asset('openadapt_desktop-0.3.2-py3-none-any.whl')], + } + const draft = experimentalRelease({ draft: true }) + const incomplete = experimentalRelease({ + tag_name: 'desktop-v0.2.0', + assets: [asset('unrelated.msi')], }) - cy.visit('/download') - cy.contains('Experimental desktop') - cy.contains('No public desktop installer yet') - cy.contains('Install the working CLI') - .should('have.attr', 'href') - .and('equal', 'https://docs.openadapt.ai') - cy.contains('All downloads').should('not.exist') - cy.contains('First launch').should('not.exist') + expect( + selectExperimentalDesktopRelease([legacy, draft, incomplete]) + ).to.equal(null) }) - it('selects a complete desktop-v Experimental prerelease and exact assets', () => { + it('selects the newest complete desktop-v Experimental prerelease', () => { const incomplete = experimentalRelease({ tag_name: 'desktop-v0.2.0', assets: [asset('unrelated.msi')], }) - cy.intercept('GET', RELEASES_API, { - body: [incomplete, experimentalRelease()], + const complete = experimentalRelease() + const selected = selectExperimentalDesktopRelease([ + incomplete, + complete, + ]) + + expect(selected).to.equal(complete) + const windows = DESKTOP_PLATFORMS.find( + (platform) => platform.id === 'windows' + ) + expect(assetForPlatform(selected.assets, windows).name).to.equal( + 'OpenAdapt-Desktop-Experimental-v0.1.0-windows-x86_64-unsigned.msi' + ) + expect(releaseSigningState(selected.assets)).to.deep.equal({ + macosNotarized: false, + windowsSigned: false, }) - cy.visit('/download') + }) - cy.contains('Experimental prerelease desktop-v0.1.0') - cy.contains('All downloads') - cy.contains('Windows (x64)') - .parents('.rounded-xl') - .contains('Download') - .should( - 'have.attr', - 'href', - 'https://downloads.example/OpenAdapt-Desktop-Experimental-v0.1.0-windows-x86_64-unsigned.msi' - ) - cy.contains('macOS (Apple Silicon)') - cy.contains('macOS (Intel)') - cy.contains('Linux (x64)') - cy.contains('The DMGs are ad-hoc signed') - cy.contains('The Windows installers are not yet Authenticode signed') - cy.contains('Download SHA256SUMS') - .should('have.attr', 'href') - .and('equal', 'https://downloads.example/SHA256SUMS') + it('does not guess unsupported or ambiguous architectures', () => { + expect( + detectDesktopPlatform({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', + platform: 'MacIntel', + }) + ).to.equal(null) + expect( + detectDesktopPlatform({ + userAgent: 'Mozilla/5.0 (X11; Linux aarch64)', + platform: 'Linux armv8l', + }) + ).to.equal(null) + expect( + detectDesktopPlatform({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + platform: 'Win32', + }) + ).to.equal('windows') }) }) From aadbb9c198aa5c9bf40ad7e6469a58b2aa66bf04 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 16 Jul 2026 01:29:26 -0400 Subject: [PATCH 4/4] chore: leave launch navigation to the launch branch --- components/NavHeader.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/NavHeader.js b/components/NavHeader.js index 9599678e..96a791cf 100644 --- a/components/NavHeader.js +++ b/components/NavHeader.js @@ -17,7 +17,6 @@ const NAV_LINKS = [ { label: 'Safety', href: '/safety' }, { label: 'Compare', href: '/compare' }, { label: 'Pricing', href: '/#pricing' }, - { label: 'Download', href: '/download' }, { label: 'About', href: '/about' }, { label: 'Open source', @@ -105,8 +104,7 @@ export default function NavHeader() { rel="noopener noreferrer" onClick={() => { const ev = externalEvent(item.href) - if (ev) - track(ev, { location: 'nav_mobile' }) + if (ev) track(ev, { location: 'nav_mobile' }) }} > {item.label}