diff --git a/apps/web/public/elements/about.png b/apps/web/public/elements/about.png new file mode 100644 index 000000000..33ce7693b Binary files /dev/null and b/apps/web/public/elements/about.png differ diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx index ae912aeb6..f355b353e 100644 --- a/apps/web/src/components/layout/UserMenu.tsx +++ b/apps/web/src/components/layout/UserMenu.tsx @@ -1,12 +1,30 @@ 'use client'; -import { BookMarked, LogOut, Avatar } from '@/components/system'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { + Avatar, + BookMarked, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + ExternalLink, + Info, + LogOut, +} from '@/components/system'; import { useUser } from '@/hooks/useUser'; import { authClient } from '@/lib/auth-client'; import { DOCS_BASE_URL } from '@/lib/docs'; +import { isParsableProductVersion, toReleaseTag } from '@/lib/product-version'; +import { GITHUB_RELEASES_BASE_URL } from '@/lib/release-links'; import { PERSONAL_THEME_STORAGE_KEY } from '@/types/preferences'; import { cn } from '@/lib/utils'; +import { useTRPC } from '@/trpc/client'; import { DropdownMenu, @@ -15,6 +33,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/system'; +import Image from 'next/image'; export const UserMenu = ({ portalContainer, @@ -56,6 +75,14 @@ function SignedInUserMenu({ switchOrgRedirectPath?: string; user: NonNullable['user']>; }) { + const trpc = useTRPC(); + const [isAboutOpen, setIsAboutOpen] = useState(false); + const statusQuery = useQuery( + trpc.releases.status.queryOptions(undefined, { + staleTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }), + ); const handleSignOut = async () => { window.localStorage.removeItem(PERSONAL_THEME_STORAGE_KEY); await authClient.signOut({ @@ -69,6 +96,11 @@ function SignedInUserMenu({ const userDisplayName = user.name ?? 'You'; const userEmail = user.resource.primaryEmailAddress?.emailAddress; + const displayVersion = statusQuery.data?.displayVersion ?? null; + const isReleaseVersion = isParsableProductVersion(displayVersion); + const releaseUrl = displayVersion + ? `${GITHUB_RELEASES_BASE_URL}/#release-${toReleaseTag(displayVersion)}` + : null; return ( <> @@ -121,6 +153,15 @@ function SignedInUserMenu({ + setIsAboutOpen(true)}> + + About Roomote + {displayVersion && ( + + {displayVersion} + + )} + + + + + About Roomote + {displayVersion && ( + + You're running Roomote version{' '} + + {isReleaseVersion && releaseUrl ? ( + + {displayVersion} + + ) : ( + displayVersion + )} + + + )} + + +

+ Made with care by humans and robots. +

+
+ +
+ + + ); } diff --git a/apps/web/src/lib/__tests__/product-version.test.ts b/apps/web/src/lib/__tests__/product-version.test.ts index 80192fe34..702202756 100644 --- a/apps/web/src/lib/__tests__/product-version.test.ts +++ b/apps/web/src/lib/__tests__/product-version.test.ts @@ -20,6 +20,7 @@ describe('product-version', () => { expect(isParsableProductVersion('v0.16.0')).toBe(true); expect(isParsableProductVersion('0.16.0-rc.1')).toBe(true); expect(isParsableProductVersion('main-037146ca')).toBe(false); + expect(isParsableProductVersion('037146ca')).toBe(false); expect(isParsableProductVersion('develop-abc12345')).toBe(false); expect(isParsableProductVersion('self-host-production')).toBe(false); expect(isParsableProductVersion(null)).toBe(false); diff --git a/apps/web/src/lib/product-version.ts b/apps/web/src/lib/product-version.ts index c670d9706..fa5e547a8 100644 --- a/apps/web/src/lib/product-version.ts +++ b/apps/web/src/lib/product-version.ts @@ -142,7 +142,7 @@ export function isParsableProductVersion( if (!normalized) { return false; } - return parseProductVersion(normalized).segments !== null; + return /^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(normalized); } export function isProductVersionNewer( diff --git a/apps/web/src/trpc/commands/product-releases/index.test.ts b/apps/web/src/trpc/commands/product-releases/index.test.ts index 385b1905d..9984a475d 100644 --- a/apps/web/src/trpc/commands/product-releases/index.test.ts +++ b/apps/web/src/trpc/commands/product-releases/index.test.ts @@ -81,6 +81,7 @@ const memberAuth = { describe('releases commands', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); mockEnv.RELEASE_VERSION = 'v0.14.0'; delete mockEnv.RELEASE_PRODUCT_VERSION; mockIsRoomoteCloudEnabled.mockReturnValue(false); @@ -96,6 +97,7 @@ describe('releases commands', () => { it('exposes update status only to self-host admins', async () => { await expect(getReleaseStatusCommand(adminAuth)).resolves.toEqual({ runningVersion: '0.14.0', + displayVersion: 'v0.14.0', latestKnownVersion: '0.15.0', latestVersionCheckedAt: '2026-07-20T00:00:00.000Z', updateAvailable: true, @@ -103,6 +105,7 @@ describe('releases commands', () => { await expect(getReleaseStatusCommand(memberAuth)).resolves.toEqual({ runningVersion: '0.14.0', + displayVersion: 'v0.14.0', latestKnownVersion: null, latestVersionCheckedAt: null, updateAvailable: false, @@ -115,12 +118,13 @@ describe('releases commands', () => { }); }); - it('prefers the baked product version over a channel build tag', async () => { + it('uses the commit for display while preserving the baked product version for notices', async () => { mockEnv.RELEASE_VERSION = 'main-037146ca'; mockEnv.RELEASE_PRODUCT_VERSION = '0.14.0'; await expect(getReleaseStatusCommand(adminAuth)).resolves.toMatchObject({ runningVersion: '0.14.0', + displayVersion: '037146ca', updateAvailable: true, }); }); @@ -130,10 +134,20 @@ describe('releases commands', () => { await expect(getReleaseStatusCommand(adminAuth)).resolves.toMatchObject({ runningVersion: 'main-037146ca', + displayVersion: '037146ca', updateAvailable: false, }); }); + it('uses the GitHub build commit when a branch build has no named version', async () => { + mockEnv.RELEASE_VERSION = 'main'; + vi.stubEnv('GITHUB_SHA', '037146ca'); + + await expect(getReleaseStatusCommand(memberAuth)).resolves.toMatchObject({ + displayVersion: '037146ca', + }); + }); + it('fetches and caches release notes for the running version', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/apps/web/src/trpc/commands/product-releases/index.ts b/apps/web/src/trpc/commands/product-releases/index.ts index 82b596031..a2748d302 100644 --- a/apps/web/src/trpc/commands/product-releases/index.ts +++ b/apps/web/src/trpc/commands/product-releases/index.ts @@ -1,3 +1,5 @@ +import { execFileSync } from 'node:child_process'; + import { db, deploymentSettings, eq } from '@roomote/db/server'; import { getRedis, @@ -14,6 +16,7 @@ import { ROOMOTE_GITHUB_REPO, } from '@/lib/release-links'; import { + isParsableProductVersion, isProductVersionNewer, normalizeProductVersion, toReleaseTag, @@ -25,6 +28,7 @@ const PRODUCT_VERSION_PATTERN = /^v?\d+\.\d+\.\d+(?:-[\w.]+)?$/i; type ReleaseStatus = { runningVersion: string | null; + displayVersion: string | null; latestKnownVersion: string | null; latestVersionCheckedAt: string | null; updateAvailable: boolean; @@ -50,6 +54,53 @@ function getRunningVersion(): string | null { ); } +function getDisplayVersion(): string | null { + const releaseVersion = Env.RELEASE_VERSION?.trim(); + if (releaseVersion) { + if (isParsableProductVersion(releaseVersion)) { + return toReleaseTag(releaseVersion); + } + + // Channel builds include their commit after the final dash. + const commitFromRelease = releaseVersion.match(/[a-f0-9]{7,40}$/i)?.[0]; + if (commitFromRelease) { + return commitFromRelease; + } + } + + // Channel image builds receive their commit from GitHub Actions. A source + // checkout (the normal development path) has neither build metadata value, + // so resolve HEAD directly instead. + const commit = process.env.GITHUB_SHA?.trim() || getLocalGitCommit(); + if (commit) { + return commit; + } + + const productVersion = normalizeProductVersion(Env.RELEASE_PRODUCT_VERSION); + if (productVersion) { + return toReleaseTag(productVersion); + } + + // Keep a useful identifier for deployments that do not expose commit + // metadata (for example a locally built self-hosted image). + return releaseVersion ?? null; +} + +function getLocalGitCommit(): string | null { + try { + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + cwd: process.cwd(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return commit || null; + } catch { + // Release images do not include .git. They always have RELEASE_VERSION, + // but keep this fallback safe for custom and detached deployments. + return null; + } +} + function canSeeUpdateStatus(auth: UserAuthSuccess): boolean { return auth.isAdmin && !isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED); } @@ -62,10 +113,12 @@ export async function getReleaseStatusCommand( auth: UserAuthSuccess, ): Promise { const runningVersion = getRunningVersion(); + const displayVersion = getDisplayVersion(); if (!canSeeUpdateStatus(auth)) { return { runningVersion, + displayVersion, latestKnownVersion: null, latestVersionCheckedAt: null, updateAvailable: false, @@ -88,6 +141,7 @@ export async function getReleaseStatusCommand( return { runningVersion, + displayVersion, latestKnownVersion, latestVersionCheckedAt, updateAvailable: isProductVersionNewer(latestKnownVersion, runningVersion),