diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 10320f12..0b811936 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -135,6 +135,7 @@ jobs: release/Lightcode-*.AppImage release/Lightcode-*.deb release/latest*.yml + release/nightly*.yml release/*.blockmap if-no-files-found: error retention-days: 7 diff --git a/src/renderer/views/SettingsOverlay/parts/AboutSettings.tsx b/src/renderer/views/SettingsOverlay/parts/AboutSettings.tsx index 42105871..0d6c46d8 100644 --- a/src/renderer/views/SettingsOverlay/parts/AboutSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/AboutSettings.tsx @@ -5,7 +5,8 @@ import { PixelLoader } from "@/renderer/components/common"; import { useUpdateStore } from "@/renderer/state/updateStore"; import { productNameFor } from "@/shared/channel"; import { formatBytes } from "@/shared/formatBytes"; -import appIconUrl from "../../../../../build/icon.png"; +import appIconStableUrl from "../../../../../build/icon.png"; +import appIconNightlyUrl from "../../../../../build/icon-nightly.png"; const GITHUB_REPO = "https://github.com/nicepkg/lightcode"; const WEBSITE_URL = "https://www.lightcodeapp.com/"; @@ -88,6 +89,7 @@ function UpdateButton() { export function AboutSettings() { const bridge = readBridge(); const productName = productNameFor(bridge.channel); + const appIconUrl = bridge.channel === "nightly" ? appIconNightlyUrl : appIconStableUrl; return (
diff --git a/website/src/app/download/download-content.tsx b/website/src/app/download/download-content.tsx index 65eb9e78..df201327 100644 --- a/website/src/app/download/download-content.tsx +++ b/website/src/app/download/download-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { Download, ArrowLeft, Monitor, Apple, Terminal } from "lucide-react"; +import { Download, ArrowLeft, Monitor, Apple, Terminal, Moon } from "lucide-react"; import { motion } from "framer-motion"; import Link from "next/link"; import { downloadUrlFor, type ReleaseInfo } from "@/lib/releases"; @@ -46,6 +46,13 @@ export function DownloadContent({ release }: { release: ReleaseInfo }) { Back to home + + + Nightly builds → + {/* Content */} diff --git a/website/src/app/home-content.tsx b/website/src/app/home-content.tsx index e22f0a9d..8626fab9 100644 --- a/website/src/app/home-content.tsx +++ b/website/src/app/home-content.tsx @@ -324,20 +324,18 @@ export function HomeContent({ release }: { release: ReleaseInfo }) { Download for {platform.label} - - View on GitHub - Other platforms + + Nightly builds +
diff --git a/website/src/app/nightly/nightly-content.tsx b/website/src/app/nightly/nightly-content.tsx new file mode 100644 index 00000000..3b5a053c --- /dev/null +++ b/website/src/app/nightly/nightly-content.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Download, ArrowLeft, Monitor, Apple, Terminal, Moon, AlertTriangle } from "lucide-react"; +import { motion } from "framer-motion"; +import Link from "next/link"; +import { downloadUrlFor, type ReleaseInfo } from "@/lib/releases"; + +const PLATFORMS = [ + { + os: "macOS", + icon: Apple, + variants: [ + { label: "arm", slug: "mac-arm64", ext: ".dmg" }, + { label: "Intel", slug: "mac-x64", ext: ".dmg" }, + ], + }, + { + os: "Windows", + icon: Monitor, + variants: [ + { label: "x64", slug: "win-x64", ext: ".exe" }, + { label: "ARM64", slug: "win-arm64", ext: ".exe" }, + ], + }, + { + os: "Linux", + icon: Terminal, + variants: [{ label: "x64 (AppImage)", slug: "linux-x64", ext: ".AppImage" }], + }, +]; + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const diffMs = Date.now() - then; + const minutes = Math.round(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.round(days / 30); + return `${months}mo ago`; +} + +function formatBuildTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); +} + +export function NightlyContent({ release }: { release: ReleaseInfo }) { + const hasBuild = release.version !== null; + const publishedAt = release.publishedAt ?? null; + const [relativeTime, setRelativeTime] = useState(""); + const [absoluteTime, setAbsoluteTime] = useState(""); + + useEffect(() => { + if (!publishedAt) return; + setRelativeTime(formatRelative(publishedAt)); + setAbsoluteTime(formatBuildTime(publishedAt)); + }, [publishedAt]); + + return ( +
+ {/* Background */} +
+ + {/* Navigation */} + + + {/* Content */} +
+ +
+ + Nightly +
+ +

Lightcode Nightly

+ + {hasBuild ? ( +
+ + v{release.version} + + {relativeTime ? ( + + built {relativeTime} + {absoluteTime ? · {absoluteTime} : null} + + ) : null} +
+ ) : null} + +

+ A prerelease build — try new features as soon as possible. +

+ +
+ +

+ Pre-release builds for testing. Expect rough edges — features may be incomplete or + change without notice. Prefer the{" "} + + stable download + {" "} + for day-to-day use. +

+
+
+ + {hasBuild ? ( +
+ {PLATFORMS.map((platform, i) => ( + +
+ +

{platform.os}

+
+ +
+ ))} +
+ ) : ( + +

+ No nightly build is available yet. Check the{" "} + + releases page + {" "} + or come back soon. +

+
+ )} + + +

+ All builds are published as{" "} + + GitHub prereleases + + . +

+
+
+
+ ); +} diff --git a/website/src/app/nightly/page.tsx b/website/src/app/nightly/page.tsx new file mode 100644 index 00000000..7d88cf2d --- /dev/null +++ b/website/src/app/nightly/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +import { getLatestNightlyRelease } from "@/lib/releases"; +import { NightlyContent } from "./nightly-content"; + +export const metadata: Metadata = { + title: "Lightcode Nightly — Latest pre-release builds", + description: + "Download the latest Lightcode nightly build. Pre-release installers with the newest changes, refreshed automatically from CI.", +}; + +export default async function NightlyPage() { + const release = await getLatestNightlyRelease(); + return ; +} diff --git a/website/src/lib/releases.ts b/website/src/lib/releases.ts index d99d7e1f..f991c6b7 100644 --- a/website/src/lib/releases.ts +++ b/website/src/lib/releases.ts @@ -1,11 +1,13 @@ const GITHUB_REPO = "SDSLeon/lightcode"; const RELEASES_LATEST_URL = `https://github.com/${GITHUB_REPO}/releases/latest`; +const RELEASES_INDEX_URL = `https://github.com/${GITHUB_REPO}/releases`; +const NIGHTLY_TAG_PATTERN = /-nightly\./; export const PLATFORM_PATTERNS: Record = { "mac-arm64": /Lightcode-.*-arm64\.dmg$/, "mac-x64": /Lightcode-.*-x64\.dmg$/, - "win-x64": /Lightcode-Setup-.*-x64\.exe$/, - "win-arm64": /Lightcode-Setup-.*-arm64\.exe$/, + "win-x64": /Lightcode-.*Setup-.*-x64\.exe$/, + "win-arm64": /Lightcode-.*Setup-.*-arm64\.exe$/, "linux-x64": /Lightcode-.*-x86_64\.AppImage$/, }; @@ -20,12 +22,40 @@ interface GitHubReleaseResponse { tag_name: string; html_url: string; assets: GitHubAsset[]; + prerelease?: boolean; + published_at?: string | null; } export interface ReleaseInfo { version: string | null; releasesUrl: string; downloads: Partial>; + publishedAt?: string | null; +} + +function buildReleaseInfo(release: GitHubReleaseResponse): ReleaseInfo { + const downloads: Record = {}; + for (const [slug, pattern] of Object.entries(PLATFORM_PATTERNS)) { + const asset = release.assets.find((a) => pattern.test(a.name)); + if (asset) downloads[slug] = asset.browser_download_url; + } + return { + version: release.tag_name.replace(/^v/, ""), + releasesUrl: release.html_url, + downloads, + publishedAt: release.published_at ?? null, + }; +} + +function githubHeaders(): Record { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Lightcode-Website", + }; + // Optional: required when the repo is private. + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; } /** @@ -35,38 +65,62 @@ export interface ReleaseInfo { */ export async function getLatestRelease(): Promise { try { - const headers: Record = { - Accept: "application/vnd.github.v3+json", - "User-Agent": "Lightcode-Website", - }; - // Optional: required when the repo is private. - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; - if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { - headers, + headers: githubHeaders(), next: { revalidate: 300 }, }); if (!res.ok) throw new Error(`GitHub API error: ${res.status}`); const release = (await res.json()) as GitHubReleaseResponse; - const downloads: Record = {}; - for (const [slug, pattern] of Object.entries(PLATFORM_PATTERNS)) { - const asset = release.assets.find((a) => pattern.test(a.name)); - if (asset) downloads[slug] = asset.browser_download_url; - } - + return buildReleaseInfo(release); + } catch { return { - version: release.tag_name.replace(/^v/, ""), - releasesUrl: release.html_url, - downloads, + version: null, + releasesUrl: RELEASES_LATEST_URL, + downloads: {}, + publishedAt: null, }; + } +} + +/** + * Fetches the latest nightly prerelease. GitHub's `/releases/latest` endpoint + * skips prereleases, so we list all releases and pick the most recent one + * tagged `*-nightly.*` with `prerelease: true`. Results are cached for 5 + * minutes via Next.js fetch revalidation. + */ +export async function getLatestNightlyRelease(): Promise { + try { + const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=30`, { + headers: githubHeaders(), + next: { revalidate: 300 }, + }); + + if (!res.ok) throw new Error(`GitHub API error: ${res.status}`); + + const releases = (await res.json()) as GitHubReleaseResponse[]; + // GitHub returns releases sorted by created_at desc, so the first match wins. + const nightly = releases.find( + (r) => r.prerelease === true && NIGHTLY_TAG_PATTERN.test(r.tag_name), + ); + + if (!nightly) { + return { + version: null, + releasesUrl: RELEASES_INDEX_URL, + downloads: {}, + publishedAt: null, + }; + } + + return buildReleaseInfo(nightly); } catch { return { version: null, - releasesUrl: RELEASES_LATEST_URL, + releasesUrl: RELEASES_INDEX_URL, downloads: {}, + publishedAt: null, }; } }