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
65 changes: 65 additions & 0 deletions src/vertex/app/api/latest-release/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';

interface GitHubAsset {
name: string;
browser_download_url: string;
}

interface GitHubRelease {
tag_name: string;
assets: GitHubAsset[];
}

export const revalidate = 3600;

export async function GET() {
try {
const res = await fetch(
'https://api.github.com/repos/airqo-platform/AirQo-frontend/releases?per_page=20',
{
headers: {
Accept: 'application/vnd.github+json',
...(process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {}),
},
next: { revalidate: 3600 },
}
);

if (!res.ok) {
return NextResponse.json({ error: 'Failed to fetch releases' }, { status: 502 });
}

const releases: GitHubRelease[] = await res.json();

// Desktop releases are identified by having a .exe asset (v0.x.y series)
const desktop = releases.find((r) => r.assets.some((a) => a.name.endsWith('.exe')));

if (!desktop) {
return NextResponse.json({ error: 'No desktop release found' }, { status: 404 });
}

const find = (match: (name: string) => boolean) =>
desktop.assets.find((a) => match(a.name))?.browser_download_url ?? null;

return NextResponse.json({
version: desktop.tag_name,
windows: {
exe: find((n) => n.endsWith('.exe')),
},
mac: {
arm64Dmg: find((n) => n.includes('arm64') && n.endsWith('.dmg')),
arm64Zip: find((n) => n.includes('arm64') && n.endsWith('.zip')),
intelDmg: find((n) => !n.includes('arm64') && n.endsWith('.dmg')),
intelZip: find((n) => !n.includes('arm64') && n.endsWith('.zip')),
},
linux: {
appImage: find((n) => n.endsWith('.AppImage')),
deb: find((n) => n.endsWith('.deb')),
},
});
} catch {
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
Comment on lines +62 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Log errors in the catch block before returning 500.

The catch block silently swallows the error with no logging. In production, if this route starts returning 500s, there's no way to diagnose the root cause (e.g., GitHub API outage, parse failure, network timeout). Bind the error and log it.

🔧 Proposed fix
-  } catch {
+  } catch (error) {
+    console.error('[latest-release] Failed to fetch or parse GitHub releases:', error);
     return NextResponse.json({ error: 'Internal error' }, { status: 500 });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch {
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
} catch (error) {
console.error('[latest-release] Failed to fetch or parse GitHub releases:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vertex/app/api/latest-release/route.ts` around lines 62 - 64, The catch
block in the latest-release route is swallowing the underlying failure, so
update the handler to bind the thrown error and log it before returning the 500
response. Use the route handler in route.ts and the surrounding try/catch around
the GitHub release fetch/parse logic to emit a meaningful error message with the
actual exception details, then keep the existing NextResponse.json fallback.

}
82 changes: 82 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,88 @@

> **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend.

## Version 2.0.21
**Released:** July 9, 2026

### Chore: Increase Bundle Size Limit to 205 kB

Raised the `size-limit` threshold in `package.json` from `200 kB` to `205 kB` to accommodate the new platform-aware download page, OS detection utilities, logout confirmation dialog, and sidebar CTA — features added across versions 2.0.18–2.0.20 that collectively pushed the brotlied app chunk total to ~202 kB.

- `src/vertex/package.json` [MODIFIED] — `"limit": "200 kB"` → `"limit": "205 kB"`

---

## Version 2.0.20
**Released:** July 9, 2026

### Platform-Aware Download Page with Dynamic GitHub Release Fetching

Redesigned the `/download` page with a dynamic primary download button that detects the visitor's OS, a multi-platform release card section (macOS, Windows, Linux), and a server-side Route Handler that fetches the latest desktop release from GitHub automatically — no more hardcoded version URLs.

<details>
<summary><strong>New: Route Handler — <code>app/api/latest-release/route.ts</code></strong></summary>

- Fetches the GitHub Releases API (`/repos/airqo-platform/AirQo-frontend/releases`) server-side.
- Identifies desktop releases (v0.x.y) by the presence of a `.exe` asset, distinguishing them from web releases (v2.x.x) which have zero assets.
- Returns parsed download URLs per platform/format: Windows `.exe`, macOS Apple Silicon `.dmg`, macOS Intel `.dmg`, Linux `.AppImage`, Linux `.deb`.
- `export const revalidate = 3600` — Next.js ISR caches the response for 1 hour so GitHub is not hit on every page load.
- Optional `GITHUB_TOKEN` environment variable to avoid anonymous rate limits.

</details>

<details>
<summary><strong>New: OS detection utilities</strong></summary>

- `core/utils/platform.ts` — added `getDetectedPlatform()` (returns `'win' | 'mac' | 'linux' | 'other'`) and `getIsElectron()` as pure functions alongside the existing `getMacArchitecture()`.
- `core/hooks/useDetectedPlatform.ts` [NEW] — React hook wrapping the above pure functions; uses `useEffect` to avoid SSR hydration mismatches. Returns `{ platform, isElectron }`.

</details>

<details>
<summary><strong>DownloadHero — <code>components/features/download/DownloadHero.tsx</code></strong></summary>

- **Primary button**: dynamically labelled ("Download for Windows / macOS / Linux" or "Get Desktop app") and linked based on the detected OS. Defaults to best format per platform: Apple Silicon `.dmg` for macOS, `.AppImage` for Linux.
- **Fallback**: starts with hardcoded v0.1.11 URLs from `app-downloads.ts`; swaps to live data once the Route Handler responds.
- **Platform cards**: three cards (macOS, Windows, Linux) each listing their available formats as download links. Linux card includes a terminal install snippet (`curl -fsSL https://vertex.airqo.net/install.sh | bash`) with an inline copy button.
- Detected OS card gets a subtle primary-coloured ring highlight.
- Dark mode: cards use `bg-card`; hero section uses `bg-primary-50 dark:bg-background`.

</details>

<details>
<summary><strong>Sidebar & Login — non-Electron CTA</strong></summary>

- `components/layout/secondary-sidebar.tsx` — replaced inline OS detection with `useDetectedPlatform`; CTA ("Get Desktop app") now shows for all non-Electron users, not just Windows.
- `app/login/page.tsx` — same: removed manual state/effect OS detection, now uses `useDetectedPlatform`; CTA shows for all non-Electron users.
- `components/features/auth/social-auth-section.tsx` — replaced inline `window.navigator.userAgent` check with `useDetectedPlatform`.

</details>

<details>
<summary><strong>Download page scroll fix (<code>app/download/page.tsx</code>)</strong></summary>

- `globals.css` sets `html { overflow: hidden }` globally; the download page (a public route with no authenticated shell) had no scroll container of its own. Fixed by making the page wrapper `h-screen overflow-y-auto`.

</details>

<details>
<summary><strong>Files Modified &amp; Added (10)</strong></summary>

- `src/vertex/app/api/latest-release/route.ts` [NEW]
- `src/vertex/core/hooks/useDetectedPlatform.ts` [NEW]
- `src/vertex/core/utils/platform.ts` [MODIFIED]
- `src/vertex/core/constants/app-downloads.ts` [MODIFIED]
- `src/vertex/components/features/download/DownloadHero.tsx` [MODIFIED]
- `src/vertex/components/features/download/PlatformInfo.tsx` [MODIFIED]
- `src/vertex/app/download/page.tsx` [MODIFIED]
- `src/vertex/components/layout/secondary-sidebar.tsx` [MODIFIED]
- `src/vertex/app/login/page.tsx` [MODIFIED]
- `src/vertex/components/features/auth/social-auth-section.tsx` [MODIFIED]

</details>

---

## Version 2.0.19
**Released:** July 9, 2026

Expand Down
4 changes: 2 additions & 2 deletions src/vertex/app/download/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import DownloadTopbar from '@/components/layout/DownloadTopbar';
export const metadata: Metadata = {
title: 'Download Vertex Desktop',
description:
'Download the AirQo Vertex Desktop application for Windows and manage device deployment workflows from your desktop.',
'Download the AirQo Vertex Desktop application for macOS, Windows, and Linux. Manage device deployment workflows from your desktop.',
};

export default function DownloadPage() {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="h-screen overflow-y-auto bg-background text-foreground">
<DownloadTopbar />
<main>
<DownloadHero />
Expand Down
25 changes: 4 additions & 21 deletions src/vertex/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ROUTE_LINKS } from "@/core/routes";
import SocialAuthSection from "@/components/features/auth/social-auth-section";
import { motion, AnimatePresence } from "framer-motion";
import { vertexConfig } from "@/vertex.config";
import { useDetectedPlatform } from "@/core/hooks/useDetectedPlatform";


const loginSchema = z.object({
Expand Down Expand Up @@ -84,8 +85,7 @@ export default function LoginPage() {
const dispatch = useAppDispatch();
const isMounted = useRef(true);

const [platform, setPlatform] = useState<'win' | 'linux' | 'other' | null>(null);
const [isElectron, setIsElectron] = useState(false);
const { isElectron } = useDetectedPlatform();

useEffect(() => {
isMounted.current = true;
Expand Down Expand Up @@ -115,20 +115,6 @@ export default function LoginPage() {
};
checkExistingSession();

// OS Detection for download link and platform check
const userAgent = window.navigator.userAgent.toLowerCase();
const isWin = userAgent.includes('win');
const isLinux = userAgent.includes('linux');
setIsElectron(userAgent.includes('electron'));

if (isWin) {
setPlatform('win');
} else if (isLinux) {
setPlatform('linux');
} else {
setPlatform('other');
}

const authError = searchParams.get('error');
if (authError === 'oauth_failed') {
showBanner({
Expand Down Expand Up @@ -257,18 +243,15 @@ export default function LoginPage() {
/>
</div>

{!isElectron && platform === 'win' && (
{!isElectron && (
<div className="flex items-center">
<Link
href={ROUTE_LINKS.DOWNLOAD}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md border border-border bg-primary px-4 py-2 text-sm font-medium text-white transition-all hover:bg-primary/80 hover:border-foreground/20 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M0 3.449L9.75 2.1V11.7H0V3.449zm0 9.151h9.75v9.6L0 20.551V12.6zm10.55-10.701L24 0v11.7h-13.45V1.899zm0 10.701H24V24l-13.45-1.899V12.6z"/>
</svg>
Download for Windows
Get Desktop app
</Link>
</div>
)}
Expand Down
5 changes: 2 additions & 3 deletions src/vertex/components/features/auth/social-auth-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { cn } from '@/lib/utils';
import { useBanner } from '@/context/banner-context';
import { getLastActiveModule } from '@/core/utils/userPreferences';
import { useDetectedPlatform } from '@/core/hooks/useDetectedPlatform';

interface SocialAuthSectionProps {
mode: 'login' | 'register';
Expand Down Expand Up @@ -83,9 +84,7 @@ export default function SocialAuthSection({
]
: SOCIAL_PROVIDERS;

const isElectron =
typeof window !== 'undefined' &&
window.navigator.userAgent.toLowerCase().includes('electron');
const { isElectron } = useDetectedPlatform();

const handleSocialAuth = useCallback(
(provider: SupportedSocialAuthProvider) => {
Expand Down
Loading
Loading