diff --git a/src/app/api/releases/check/route.ts b/src/app/api/releases/check/route.ts new file mode 100644 index 0000000..d5e202d --- /dev/null +++ b/src/app/api/releases/check/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { APP_VERSION } from '@/lib/version' + +const GITHUB_RELEASES_URL = + 'https://api.github.com/repos/builderz-labs/mission-control/releases/latest' + +/** Simple semver compare: returns 1 if a > b, -1 if a < b, 0 if equal. */ +function compareSemver(a: string, b: string): number { + const pa = a.replace(/^v/, '').split('.').map(Number) + const pb = b.replace(/^v/, '').split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] ?? 0 + const nb = pb[i] ?? 0 + if (na > nb) return 1 + if (na < nb) return -1 + } + return 0 +} + +export async function GET() { + try { + const res = await fetch(GITHUB_RELEASES_URL, { + headers: { Accept: 'application/vnd.github+json' }, + next: { revalidate: 3600 }, // ISR cache for 1 hour + }) + + if (!res.ok) { + return NextResponse.json( + { updateAvailable: false, currentVersion: APP_VERSION }, + { headers: { 'Cache-Control': 'public, max-age=3600' } } + ) + } + + const release = await res.json() + const latestVersion = (release.tag_name ?? '').replace(/^v/, '') + const updateAvailable = compareSemver(latestVersion, APP_VERSION) > 0 + + return NextResponse.json( + { + updateAvailable, + currentVersion: APP_VERSION, + latestVersion, + releaseUrl: release.html_url ?? '', + releaseNotes: release.body ?? '', + }, + { headers: { 'Cache-Control': 'public, max-age=3600' } } + ) + } catch { + // Network error — fail gracefully + return NextResponse.json( + { updateAvailable: false, currentVersion: APP_VERSION }, + { headers: { 'Cache-Control': 'public, max-age=600' } } + ) + } +} diff --git a/src/components/layout/header-bar.tsx b/src/components/layout/header-bar.tsx index 8f14bd5..8d38cfb 100644 --- a/src/components/layout/header-bar.tsx +++ b/src/components/layout/header-bar.tsx @@ -7,6 +7,7 @@ import { useWebSocket } from '@/lib/websocket' import { useNavigateToPanel } from '@/lib/navigation' import { ThemeToggle } from '@/components/ui/theme-toggle' import { DigitalClock } from '@/components/ui/digital-clock' +import { APP_VERSION } from '@/lib/version' interface SearchResult { type: string @@ -136,7 +137,7 @@ export function HeaderBar() { {tabLabels[activeTab] || 'Mission Control'} - v2.0 + v{APP_VERSION}
diff --git a/src/components/layout/update-banner.tsx b/src/components/layout/update-banner.tsx new file mode 100644 index 0000000..65b374f --- /dev/null +++ b/src/components/layout/update-banner.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useMissionControl } from '@/store' + +export function UpdateBanner() { + const { updateAvailable, updateDismissedVersion, dismissUpdate } = useMissionControl() + + if (!updateAvailable) return null + if (updateDismissedVersion === updateAvailable.latestVersion) return null + + return ( +
+ +

+ + Update available: v{updateAvailable.latestVersion} + + {' — a newer version of Mission Control is available.'} +

+ + View Release + + +
+ ) +} diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..0a0331e --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,6 @@ +// Single source of truth for the application version. +// Reads from package.json at build time so every consumer +// (header, websocket handshake, API routes) stays in sync. +import pkg from '../../package.json' + +export const APP_VERSION: string = pkg.version diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index 460a475..701894a 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -9,6 +9,7 @@ import { getCachedDeviceToken, cacheDeviceToken, } from '@/lib/device-identity' +import { APP_VERSION } from '@/lib/version' // Gateway protocol version (v3 required by OpenClaw 2026.x) const PROTOCOL_VERSION = 3 @@ -172,7 +173,7 @@ export function useWebSocket() { client: { id: 'gateway-client', displayName: 'Mission Control', - version: '2.0.0', + version: APP_VERSION, platform: 'web', mode: 'ui', instanceId: `mc-${Date.now()}` diff --git a/src/middleware.ts b/src/proxy.ts similarity index 92% rename from src/middleware.ts rename to src/proxy.ts index acd8bed..44d14d9 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -1,18 +1,14 @@ +import crypto from 'node:crypto' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -/** Edge-compatible constant-time string comparison. */ +/** Constant-time string comparison using Node.js crypto. */ function safeCompare(a: string, b: string): boolean { if (typeof a !== 'string' || typeof b !== 'string') return false - const encoder = new TextEncoder() - const bufA = encoder.encode(a) - const bufB = encoder.encode(b) + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) if (bufA.length !== bufB.length) return false - let result = 0 - for (let i = 0; i < bufA.length; i++) { - result |= bufA[i] ^ bufB[i] - } - return result === 0 + return crypto.timingSafeEqual(bufA, bufB) } function envFlag(name: string): boolean { @@ -56,7 +52,7 @@ function applySecurityHeaders(response: NextResponse): NextResponse { return response } -export function middleware(request: NextRequest) { +export function proxy(request: NextRequest) { // Network access control. // In production: default-deny unless explicitly allowed. // In dev/test: allow all hosts unless overridden. diff --git a/src/store/index.ts b/src/store/index.ts index d263360..0087976 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -261,6 +261,12 @@ interface MissionControlStore { dismissBanner: () => void setSubscription: (sub: { type: string; rateLimitTier?: string } | null) => void + // Update availability + updateAvailable: { latestVersion: string; releaseUrl: string; releaseNotes: string } | null + updateDismissedVersion: string | null + setUpdateAvailable: (info: { latestVersion: string; releaseUrl: string; releaseNotes: string } | null) => void + dismissUpdate: (version: string) => void + // WebSocket & Connection connection: ConnectionStatus lastMessage: any @@ -408,6 +414,18 @@ export const useMissionControl = create()( dismissBanner: () => set({ bannerDismissed: true }), setSubscription: (sub) => set({ subscription: sub }), + // Update availability + updateAvailable: null, + updateDismissedVersion: (() => { + if (typeof window === 'undefined') return null + try { return localStorage.getItem('mc-update-dismissed-version') } catch { return null } + })(), + setUpdateAvailable: (info) => set({ updateAvailable: info }), + dismissUpdate: (version) => { + try { localStorage.setItem('mc-update-dismissed-version', version) } catch {} + set({ updateDismissedVersion: version }) + }, + // Connection state connection: { isConnected: false,