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
152 changes: 124 additions & 28 deletions lib/githubApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ async function fetchGitHubJson(pathname) {
return response.json()
}

async function fetchDesktopReleaseAssetText(assetUrl) {
const expectedPrefix =
'https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/'
if (typeof assetUrl !== 'string' || !assetUrl.startsWith(expectedPrefix)) {
throw new Error('Desktop release asset URL is outside the release repository')
}
const response = await fetch(assetUrl, { headers: githubHeaders() })
if (!response.ok) {
throw new Error(`Desktop release asset returned ${response.status}`)
}
return response.text()
}

async function fetchDesktopTagCommit(tag) {
const data = await fetchGitHubJson(
`/repos/OpenAdaptAI/openadapt-desktop/commits/${encodeURIComponent(tag)}`
)
if (!data || !/^[0-9a-f]{40}$/.test(data.sha || '')) {
throw new Error(`Desktop tag ${tag} did not resolve to one commit`)
}
return data.sha
}

/**
* Fetch current star/fork social proof and throw on failure.
* Server-side cache/fallback policy belongs to the caller.
Expand Down Expand Up @@ -96,40 +119,113 @@ export async function getOpenIssuesByLabel(repository, label) {
* "open releases on GitHub" fallback), false when GitHub answered but no
* complete prerelease exists yet.
*/
export async function getDesktopRelease() {
export async function resolveDesktopRelease(
releases,
{
fetchAssetText = fetchDesktopReleaseAssetText,
fetchTagCommit = fetchDesktopTagCommit,
} = {}
) {
const {
DESKTOP_REPO,
DESKTOP_RELEASE_MANIFEST,
desktopReleaseCandidates,
desktopReleaseLifecycle,
selectDesktopRelease,
} = await import('../utils/desktopRelease')
isLegacyBetaDesktopRelease,
validateDesktopReleaseChecksums,
validateDesktopReleaseManifest,
} = await import('../utils/desktopRelease.js')
const { createHash } = await import('node:crypto')
for (const selected of desktopReleaseCandidates(releases)) {
const strictLifecycle = desktopReleaseLifecycle(selected)
const lifecycle =
strictLifecycle ||
(isLegacyBetaDesktopRelease(selected) ? 'beta' : null)
const assets = (selected.assets || [])
.filter(
(asset) =>
asset &&
typeof asset.name === 'string' &&
typeof asset.browser_download_url === 'string'
)
.map((asset) => ({
name: asset.name,
size: typeof asset.size === 'number' ? asset.size : null,
browser_download_url: asset.browser_download_url,
}))
if (strictLifecycle !== 'beta') {
return {
release: {
tag_name: selected.tag_name || null,
name: selected.name || null,
lifecycle,
manifest: null,
assets,
},
fetchFailed: false,
}
}
try {
const manifestAsset = selected.assets.find(
(asset) => asset.name === DESKTOP_RELEASE_MANIFEST
)
const checksumAsset = selected.assets.find(
(asset) => asset.name === 'SHA256SUMS'
)
const [manifestText, checksumText, tagSourceCommit] =
await Promise.all([
fetchAssetText(manifestAsset?.browser_download_url),
fetchAssetText(checksumAsset?.browser_download_url),
fetchTagCommit(selected.tag_name),
])
const manifestData = JSON.parse(manifestText)
const manifest = validateDesktopReleaseManifest(
selected,
manifestData,
tagSourceCommit
)
const manifestDigest = createHash('sha256')
.update(manifestText)
.digest('hex')
if (
!manifest ||
!validateDesktopReleaseChecksums(
selected,
manifestData,
checksumText,
manifestDigest
)
) {
continue
}
return {
release: {
tag_name: selected.tag_name || null,
name: selected.name || null,
lifecycle,
manifest: {
...manifest,
browser_download_url:
manifestAsset.browser_download_url,
},
assets,
},
fetchFailed: false,
}
} catch (err) {
// A malformed or unavailable candidate does not hide an older
// valid strict Beta or the verified transition fallback.
}
}
return { release: null, fetchFailed: false }
}

export async function getDesktopRelease() {
const { DESKTOP_REPO } = await import('../utils/desktopRelease.js')
try {
const releases = await fetchGitHubJson(
`/repos/${DESKTOP_REPO}/releases?per_page=20`
)
const selected = selectDesktopRelease(releases)
if (!selected) {
return { release: null, fetchFailed: false }
}
return {
release: {
tag_name: selected.tag_name || null,
name: selected.name || null,
lifecycle: desktopReleaseLifecycle(selected),
assets: (selected.assets || [])
.filter(
(asset) =>
asset &&
typeof asset.name === 'string' &&
typeof asset.browser_download_url === 'string'
)
.map((asset) => ({
name: asset.name,
size: typeof asset.size === 'number' ? asset.size : null,
browser_download_url: asset.browser_download_url,
})),
},
fetchFailed: false,
}
return await resolveDesktopRelease(releases)
} catch (err) {
return { release: null, fetchFailed: true }
}
Expand Down
40 changes: 40 additions & 0 deletions pages/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export default function DownloadPage({ release, fetchFailed }) {
[assets, lifecycle]
)
const checksumAsset = assets.find((asset) => asset.name === 'SHA256SUMS')
const releaseManifest = release?.manifest || null

return (
<div className="min-h-screen bg-ground text-ink">
Expand Down Expand Up @@ -328,6 +329,45 @@ export default function DownloadPage({ release, fetchFailed }) {
{version ? ` (${version})` : ''}. Choose the one that
matches your machine.
</p>
{releaseManifest && (
<div className="mt-5 rounded-xl border border-hairline bg-panel p-5">
<p className="font-display text-base font-semibold text-ink">
Verified release manifest
</p>
<p className="mt-2 text-sm leading-relaxed text-ink-2">
This release binds {releaseManifest.artifactCount}{' '}
installer files and its CycloneDX SBOM to exact
SHA-256 values. Build source:{' '}
<a
href={`https://github.com/OpenAdaptAI/openadapt-desktop/commit/${releaseManifest.sourceCommit}`}
className="font-mono underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
>
{releaseManifest.sourceCommit.slice(0, 12)}
</a>
.
</p>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<a
href={releaseManifest.browser_download_url}
className="font-medium underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
>
Release manifest
</a>
<a
href={releaseManifest.sbom.browser_download_url}
className="font-medium underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
>
CycloneDX SBOM
</a>
</div>
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
{platformDownloads.map(({ platform, asset }) => (
<div
Expand Down
Loading
Loading