add version in top bar and update checking

This commit is contained in:
Boof2015
2026-04-22 00:05:56 -04:00
parent 70d299d5b8
commit 7044a331ae
17 changed files with 1154 additions and 3 deletions
+167
View File
@@ -1,6 +1,9 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, safeStorage, screen, session, shell } from 'electron'
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
import { execFileSync } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { extname, join, resolve } from 'path'
import type { AppBuildInfo } from '../types/appBuildInfo'
import type { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying'
import type {
ScopePopoutAudioBatch,
@@ -33,6 +36,7 @@ import { NowPlayingManager } from './services/nowPlayingManager'
import { AstraIntegrationService } from './services/astraIntegration'
import { MacSpotifyProvider } from './services/macSpotifyProvider'
import { SecretVault } from './services/secretVault'
import { checkForUpdates, resolveSafeReleaseUrl } from './services/updates'
import { FileBackedThemeLibrary } from './themeLibrary'
import { FileBackedWindowStateStore } from './windowStateStore'
import type { NativeWindowsMediaAPI } from '../types/nativeWindowsMedia'
@@ -99,6 +103,157 @@ const runtimeWindowCapabilities = resolveWindowCapabilities({
env: process.env,
})
interface ResolvedBuildMetadata {
commitHash: string | null
isDirty: boolean
}
const DIRTY_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'dirty'])
const DIRTY_ENV_FALSE_VALUES = new Set(['0', 'false', 'no', 'clean'])
let cachedBuildMetadata: ResolvedBuildMetadata | null = null
function normalizeBuildCommitHash(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function parseDirtyEnvValue(value: unknown): boolean | null {
if (typeof value !== 'string') return null
const normalized = value.trim().toLowerCase()
if (!normalized) return null
if (DIRTY_ENV_TRUE_VALUES.has(normalized)) return true
if (DIRTY_ENV_FALSE_VALUES.has(normalized)) return false
return null
}
function tryReadBuildMetadataFile(filePath: string): ResolvedBuildMetadata | null {
try {
const payload = JSON.parse(readFileSync(filePath, 'utf8')) as { commitHash?: unknown; isDirty?: unknown }
const commitHash = normalizeBuildCommitHash(payload.commitHash)
const isDirty = payload.isDirty === true
if (!commitHash) {
return {
commitHash: null,
isDirty: false,
}
}
return {
commitHash,
isDirty,
}
} catch {
return null
}
}
function tryResolveGitBuildMetadataFromDirectory(directory: string): ResolvedBuildMetadata | null {
if (!existsSync(join(directory, '.git'))) {
return null
}
try {
const commitHash = normalizeBuildCommitHash(execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: directory,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}))
if (!commitHash) {
return null
}
const isDirty = execFileSync('git', ['status', '--porcelain'], {
cwd: directory,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim().length > 0
return {
commitHash,
isDirty,
}
} catch {
return null
}
}
function resolveBuildMetadata(): ResolvedBuildMetadata {
if (cachedBuildMetadata) {
return cachedBuildMetadata
}
const envCommitHash = normalizeBuildCommitHash(
process.env.PRISM_GIT_COMMIT ?? process.env.PRISM_BUILD_COMMIT_HASH,
)
const envDirty = parseDirtyEnvValue(
process.env.PRISM_GIT_DIRTY ?? process.env.PRISM_BUILD_DIRTY,
)
if (envCommitHash) {
cachedBuildMetadata = {
commitHash: envCommitHash,
isDirty: envDirty ?? false,
}
return cachedBuildMetadata
}
const metadataFileCandidates = Array.from(new Set([
join(__dirname, '..', 'build-metadata.json'),
join(process.cwd(), 'out', 'build-metadata.json'),
join(app.getAppPath(), 'out', 'build-metadata.json'),
]))
for (const candidate of metadataFileCandidates) {
const metadata = tryReadBuildMetadataFile(candidate)
if (metadata) {
cachedBuildMetadata = {
commitHash: metadata.commitHash,
isDirty: envDirty ?? metadata.isDirty,
}
return cachedBuildMetadata
}
}
const gitDirectoryCandidates = Array.from(new Set([
process.cwd(),
app.getAppPath(),
join(__dirname, '../..'),
]))
for (const candidate of gitDirectoryCandidates) {
const metadata = tryResolveGitBuildMetadataFromDirectory(candidate)
if (metadata) {
cachedBuildMetadata = {
commitHash: metadata.commitHash,
isDirty: envDirty ?? metadata.isDirty,
}
return cachedBuildMetadata
}
}
cachedBuildMetadata = {
commitHash: null,
isDirty: false,
}
return cachedBuildMetadata
}
function getAppBuildInfo(): AppBuildInfo {
const buildMetadata = resolveBuildMetadata()
const commitHash = buildMetadata.commitHash
return {
version: app.getVersion(),
commitHash,
shortCommitHash: commitHash ? commitHash.slice(0, 7) : null,
isDirty: commitHash ? buildMetadata.isDirty : false,
}
}
function getProfileLibrary(): FileBackedProfileLibrary {
if (!profileLibrary) {
profileLibrary = new FileBackedProfileLibrary(
@@ -1290,6 +1445,18 @@ function setupIPC(): void {
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false
})
ipcMain.handle('app:get-build-info', () => {
return getAppBuildInfo()
})
ipcMain.handle('updates:check', async () => {
return checkForUpdates(app.getVersion())
})
ipcMain.handle('updates:open-releases-page', async (_event, releaseUrl: unknown) => {
await shell.openExternal(resolveSafeReleaseUrl(releaseUrl))
})
ipcMain.handle('now-playing:get-state', async () => {
return getNowPlayingManager().getState()
})
+272
View File
@@ -0,0 +1,272 @@
import type { UpdateCheckResult } from '../../types/updates'
const RELEASES_API_URL = 'https://api.github.com/repos/Boof2015/prism/releases?per_page=20'
export const RELEASES_PAGE_URL = 'https://github.com/Boof2015/prism/releases'
const RELEASES_FETCH_TIMEOUT_MS = 10_000
const RELEASES_URL_HOSTNAME = 'github.com'
const RELEASES_URL_PATH_PREFIX = '/boof2015/prism/releases'
type SemverIdentifier = number | string
interface ParsedSemverLike {
major: number
minor: number
patch: number
prerelease: SemverIdentifier[]
}
export interface GitHubReleaseResponse {
tag_name: string
name: string | null
html_url: string
draft: boolean
published_at?: string | null
created_at?: string | null
}
interface CheckForUpdatesOptions {
fetchReleases?: () => Promise<GitHubReleaseResponse[]>
}
function normalizeTag(value: string): string {
return value
.trim()
.replace(/^refs\/tags\//i, '')
.replace(/^v/i, '')
}
function parsePrerelease(value: string): SemverIdentifier[] {
return value
.split('.')
.map((identifier) => identifier.trim())
.filter((identifier) => identifier.length > 0)
.map((identifier) => (/^\d+$/.test(identifier) ? Number(identifier) : identifier.toLowerCase()))
}
function parseSemverLike(tag: string): ParsedSemverLike | null {
const normalized = normalizeTag(tag)
const [withoutBuildMetadata] = normalized.split('+', 1)
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(withoutBuildMetadata)
if (!match) return null
const major = Number(match[1])
const minor = Number(match[2])
const patch = Number(match[3])
const prerelease = match[4] ? parsePrerelease(match[4]) : []
if ([major, minor, patch].some((part) => !Number.isFinite(part))) {
return null
}
return { major, minor, patch, prerelease }
}
function compareSemverIdentifier(left: SemverIdentifier, right: SemverIdentifier): number {
if (typeof left === 'number' && typeof right === 'number') {
return left - right
}
if (typeof left === 'number' && typeof right === 'string') {
return -1
}
if (typeof left === 'string' && typeof right === 'number') {
return 1
}
return String(left).localeCompare(String(right))
}
function compareSemverLike(left: ParsedSemverLike, right: ParsedSemverLike): number {
if (left.major !== right.major) return left.major - right.major
if (left.minor !== right.minor) return left.minor - right.minor
if (left.patch !== right.patch) return left.patch - right.patch
const leftHasPrerelease = left.prerelease.length > 0
const rightHasPrerelease = right.prerelease.length > 0
if (!leftHasPrerelease && !rightHasPrerelease) return 0
if (!leftHasPrerelease) return 1
if (!rightHasPrerelease) return -1
const maxLength = Math.max(left.prerelease.length, right.prerelease.length)
for (let index = 0; index < maxLength; index += 1) {
const leftIdentifier = left.prerelease[index]
const rightIdentifier = right.prerelease[index]
if (leftIdentifier === undefined) return -1
if (rightIdentifier === undefined) return 1
const result = compareSemverIdentifier(leftIdentifier, rightIdentifier)
if (result !== 0) return result
}
return 0
}
function formatSemverLike(version: ParsedSemverLike): string {
const prerelease = version.prerelease.length > 0 ? `-${version.prerelease.join('.')}` : ''
return `${version.major}.${version.minor}.${version.patch}${prerelease}`
}
function resolveReleaseTimestamp(release: GitHubReleaseResponse): number {
const publishedAt = release.published_at ? Date.parse(release.published_at) : NaN
if (Number.isFinite(publishedAt)) {
return publishedAt
}
const createdAt = release.created_at ? Date.parse(release.created_at) : NaN
if (Number.isFinite(createdAt)) {
return createdAt
}
return 0
}
async function fetchGitHubReleases(): Promise<GitHubReleaseResponse[]> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), RELEASES_FETCH_TIMEOUT_MS)
try {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'Prism-Update-Check',
},
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`GitHub releases request failed with status ${response.status}`)
}
const payload: unknown = await response.json()
if (!Array.isArray(payload)) {
throw new Error('GitHub releases response was not an array')
}
return payload as GitHubReleaseResponse[]
} finally {
clearTimeout(timeout)
}
}
export function resolveSafeReleaseUrl(candidateUrl: unknown): string {
if (typeof candidateUrl !== 'string') {
return RELEASES_PAGE_URL
}
const trimmed = candidateUrl.trim()
if (trimmed.length === 0) {
return RELEASES_PAGE_URL
}
try {
const parsedUrl = new URL(trimmed)
const normalizedPath = parsedUrl.pathname.replace(/\/+$/, '').toLowerCase()
const isPathAllowed = normalizedPath === RELEASES_URL_PATH_PREFIX
|| normalizedPath.startsWith(`${RELEASES_URL_PATH_PREFIX}/`)
if (parsedUrl.protocol !== 'https:') {
return RELEASES_PAGE_URL
}
if (parsedUrl.hostname.toLowerCase() !== RELEASES_URL_HOSTNAME) {
return RELEASES_PAGE_URL
}
if (parsedUrl.port.length > 0) {
return RELEASES_PAGE_URL
}
if (!isPathAllowed) {
return RELEASES_PAGE_URL
}
return parsedUrl.toString()
} catch {
return RELEASES_PAGE_URL
}
}
export function resolveLatestRelease(releases: GitHubReleaseResponse[]): GitHubReleaseResponse | null {
const candidates = releases.filter((release) => !release.draft && release.tag_name.trim().length > 0)
if (candidates.length === 0) {
return null
}
const sorted = [...candidates].sort((left, right) => {
return resolveReleaseTimestamp(right) - resolveReleaseTimestamp(left)
})
return sorted[0] ?? null
}
export async function checkForUpdates(
currentVersion: string,
options: CheckForUpdatesOptions = {},
): Promise<UpdateCheckResult> {
const checkedAt = Date.now()
const normalizedCurrentVersion = normalizeTag(currentVersion)
try {
const releases = await (options.fetchReleases ?? fetchGitHubReleases)()
const latestRelease = resolveLatestRelease(releases)
if (!latestRelease) {
return {
status: 'error',
updateAvailable: false,
currentVersion,
latestTag: null,
latestVersion: null,
releaseName: null,
releaseUrl: RELEASES_PAGE_URL,
checkedAt,
message: 'No published releases were found on GitHub.',
}
}
const latestTag = latestRelease.tag_name.trim()
const parsedCurrent = parseSemverLike(currentVersion)
const parsedLatest = parseSemverLike(latestTag)
const updateAvailable = parsedCurrent && parsedLatest
? compareSemverLike(parsedLatest, parsedCurrent) > 0
: normalizeTag(latestTag) !== normalizedCurrentVersion
const latestVersion = parsedLatest ? formatSemverLike(parsedLatest) : normalizeTag(latestTag)
const releaseUrl = resolveSafeReleaseUrl(latestRelease.html_url)
if (updateAvailable) {
return {
status: 'update-available',
updateAvailable: true,
currentVersion,
latestTag,
latestVersion,
releaseName: latestRelease.name,
releaseUrl,
checkedAt,
message: `Update available: ${latestTag} (current v${currentVersion}).`,
}
}
return {
status: 'up-to-date',
updateAvailable: false,
currentVersion,
latestTag,
latestVersion,
releaseName: latestRelease.name,
releaseUrl,
checkedAt,
message: `Prism is up to date (v${currentVersion}).`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return {
status: 'error',
updateAvailable: false,
currentVersion,
latestTag: null,
latestVersion: null,
releaseName: null,
releaseUrl: RELEASES_PAGE_URL,
checkedAt,
message: `Failed to check for updates: ${errorMessage}`,
}
}
}
+7
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { AppBuildInfo } from '../types/appBuildInfo'
import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
@@ -28,6 +29,7 @@ import type {
ThemeLibrarySnapshot,
} from '../types/theme'
import type { DialogOptions, DialogResult } from '../types/dialog'
import type { UpdateCheckResult } from '../types/updates'
import type { WindowCapabilities } from '../types/windowCapabilities'
import type { ResizeDirection } from '../types/windowResize'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
@@ -45,6 +47,7 @@ const windowCapabilities: WindowCapabilities = resolveWindowCapabilities({
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
windowCapabilities,
getAppBuildInfo: () => ipcRenderer.invoke('app:get-build-info') as Promise<AppBuildInfo>,
minimize: () => ipcRenderer.send('window:minimize'),
close: () => ipcRenderer.send('window:close'),
startWindowMove: () => ipcRenderer.send('window:start-move'),
@@ -94,6 +97,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
revealThemesFolder: () => ipcRenderer.invoke('themes:reveal-folder') as Promise<void>,
migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => ipcRenderer.invoke('themes:migrate-legacy', payload) as Promise<LegacyThemeMigrationResult>,
openExternalUrl: (url: string) => ipcRenderer.invoke('shell:open-external', url) as Promise<void>,
updates: {
checkForUpdates: () => ipcRenderer.invoke('updates:check') as Promise<UpdateCheckResult>,
openReleasesPage: (releaseUrl?: string) => ipcRenderer.invoke('updates:open-releases-page', releaseUrl) as Promise<void>,
},
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
+5
View File
@@ -12,6 +12,7 @@ import { startAudioDeviceWatcher, useAudioStore } from './stores/audioStore'
import { useNowPlayingStore } from './stores/nowPlayingStore'
import { useThemeStore } from './stores/themeStore'
import { useUiStore } from './stores/uiStore'
import { useUpdateStore } from './stores/updateStore'
import { getRendererWindowCapabilities } from './windowCapabilities'
export default function App(): JSX.Element {
@@ -54,6 +55,10 @@ export default function App(): JSX.Element {
return startAudioDeviceWatcher()
}, [])
useEffect(() => {
void useUpdateStore.getState().checkForUpdates()
}, [])
useEffect(() => {
let isDisposed = false
+103
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
import type { AppBuildInfo } from '../../types/appBuildInfo'
import { useSettingsStore } from '../stores/settingsStore'
import { useUpdateStore } from '../stores/updateStore'
import { useUiStore } from '../stores/uiStore'
import { getRendererWindowCapabilities } from '../windowCapabilities'
@@ -92,6 +94,72 @@ function getErrorMessage(error: unknown, fallback: string): string {
: fallback
}
interface AppVersionBlockProps {
buildInfo: AppBuildInfo | null
updateAvailable: boolean
latestTag: string | null
releaseName: string | null
onOpenUpdate: () => void
}
function AppVersionBlock({
buildInfo,
updateAvailable,
latestTag,
releaseName,
onOpenUpdate,
}: AppVersionBlockProps): JSX.Element | null {
if (!buildInfo?.version) {
return null
}
const versionLabel = `v${buildInfo.version}`
const commitLabel = buildInfo.shortCommitHash
? `${buildInfo.shortCommitHash}${buildInfo.isDirty ? '*' : ''}`
: ''
const releaseLabel = latestTag
? `${latestTag}${releaseName ? ` (${releaseName})` : ''}`
: 'the latest release'
const buildTooltip = buildInfo.commitHash
? `Prism ${versionLabel}\nCommit: ${buildInfo.commitHash}${buildInfo.isDirty ? '\nWorking tree was dirty when this build started.' : ''}`
: `Prism ${versionLabel}`
const title = updateAvailable
? `Update available: ${releaseLabel}. Open release.`
: buildTooltip
const content = (
<>
{updateAvailable ? <span className="toolbar__version-update-dot" aria-hidden="true" /> : null}
<span className="toolbar__version-number">{versionLabel}</span>
{commitLabel ? (
<>
<span className="toolbar__version-separator" aria-hidden="true">·</span>
<span className="toolbar__version-commit">{commitLabel}</span>
</>
) : null}
</>
)
if (updateAvailable) {
return (
<button
type="button"
className="toolbar__version is-update-available"
onClick={onOpenUpdate}
title={title}
aria-label={`Update available: ${releaseLabel}. Open release.`}
>
{content}
</button>
)
}
return (
<div className="toolbar__version" title={title} aria-label={buildTooltip}>
{content}
</div>
)
}
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
const profiles = useSettingsStore((s) => s.profiles)
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
@@ -105,6 +173,11 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
const importProfileFromDialog = useSettingsStore((s) => s.importProfileFromDialog)
const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder)
const showBanner = useUiStore((s) => s.showBanner)
const updateAvailable = useUpdateStore((s) => s.updateAvailable)
const latestTag = useUpdateStore((s) => s.latestTag)
const releaseName = useUpdateStore((s) => s.releaseName)
const openReleasesPage = useUpdateStore((s) => s.openReleasesPage)
const [appBuildInfo, setAppBuildInfo] = useState<AppBuildInfo | null>(null)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
const [showReposition, setShowReposition] = useState(false)
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
@@ -142,6 +215,24 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
return unsubscribe
}, [])
useEffect(() => {
let isMounted = true
void window.electronAPI.getAppBuildInfo()
.then((buildInfo) => {
if (isMounted) {
setAppBuildInfo(buildInfo)
}
})
.catch(() => {
// Keep the toolbar usable if build metadata is unavailable.
})
return () => {
isMounted = false
}
}, [])
useEffect(() => {
if (!supportsProgrammaticReposition && showReposition) {
setShowReposition(false)
@@ -314,6 +405,10 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
window.electronAPI.toggleAlwaysOnTop()
}, [])
const handleOpenUpdate = useCallback(() => {
void openReleasesPage()
}, [openReleasesPage])
const handleReposition = useCallback((position: 'top' | 'bottom') => {
if (!supportsProgrammaticReposition) {
return
@@ -407,6 +502,14 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<span className="toolbar__brand-text">Prism</span>
</div>
<AppVersionBlock
buildInfo={appBuildInfo}
updateAvailable={updateAvailable}
latestTag={latestTag}
releaseName={releaseName}
onOpenUpdate={handleOpenUpdate}
/>
<div className="toolbar__profile">
<button
ref={profileButtonRef}
+7
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
import type { AppBuildInfo } from '../types/appBuildInfo'
import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
@@ -31,6 +32,7 @@ import type {
ThemeLibrarySnapshot,
} from '../types/theme'
import type { DialogOptions, DialogResult } from '../types/dialog'
import type { UpdateCheckResult } from '../types/updates'
import type { WindowCapabilities } from '../types/windowCapabilities'
import type { ResizeDirection } from '../types/windowResize'
@@ -41,6 +43,7 @@ declare global {
electronAPI: {
platform: string
windowCapabilities: WindowCapabilities
getAppBuildInfo: () => Promise<AppBuildInfo>
minimize: () => void
close: () => void
startWindowMove: () => void
@@ -80,6 +83,10 @@ declare global {
revealThemesFolder: () => Promise<void>
migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => Promise<LegacyThemeMigrationResult>
openExternalUrl: (url: string) => Promise<void>
updates: {
checkForUpdates: () => Promise<UpdateCheckResult>
openReleasesPage: (releaseUrl?: string) => Promise<void>
}
expandSettings: (panelHeight: number) => void
collapseSettings: (panelHeight: number) => void
setSettingsHeight: (panelHeight: number) => void
+78
View File
@@ -0,0 +1,78 @@
import { create } from 'zustand'
export type UpdateCheckState = 'idle' | 'checking' | 'up-to-date' | 'update-available' | 'error'
interface UpdateStore {
checkState: UpdateCheckState
statusMessage: string
updateAvailable: boolean
currentVersion: string | null
latestTag: string | null
latestVersion: string | null
releaseName: string | null
releaseUrl: string | null
lastCheckedAt: number | null
checkForUpdates: () => Promise<void>
openReleasesPage: (releaseUrl?: string | null) => Promise<void>
}
export const useUpdateStore = create<UpdateStore>((set, get) => ({
checkState: 'idle',
statusMessage: 'Update check has not run.',
updateAvailable: false,
currentVersion: null,
latestTag: null,
latestVersion: null,
releaseName: null,
releaseUrl: null,
lastCheckedAt: null,
checkForUpdates: async () => {
if (get().checkState === 'checking') return
set({
checkState: 'checking',
statusMessage: 'Checking for updates...',
})
try {
const result = await window.electronAPI.updates.checkForUpdates()
const releaseUrl = result.releaseUrl?.trim() || null
set({
checkState: result.status,
statusMessage: result.message,
updateAvailable: result.updateAvailable,
currentVersion: result.currentVersion,
latestTag: result.latestTag,
latestVersion: result.latestVersion,
releaseName: result.releaseName,
releaseUrl,
lastCheckedAt: result.checkedAt,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
set({
checkState: 'error',
statusMessage: `Failed to check for updates: ${message}`,
updateAvailable: false,
lastCheckedAt: Date.now(),
})
}
},
openReleasesPage: async (releaseUrl?: string | null) => {
try {
const targetReleaseUrl = typeof releaseUrl === 'string' && releaseUrl.trim().length > 0
? releaseUrl
: get().releaseUrl
await window.electronAPI.updates.openReleasesPage(targetReleaseUrl ?? undefined)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
set({
checkState: 'error',
statusMessage: `Failed to open releases page: ${message}`,
})
}
},
}))
+75
View File
@@ -372,6 +372,68 @@ select {
font-size: 10px;
}
.toolbar__version {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
max-width: 210px;
border: 0;
background: transparent;
color: var(--text-tertiary);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.1em;
white-space: nowrap;
overflow: hidden;
flex-shrink: 0;
}
button.toolbar__version {
padding: 0 2px;
cursor: pointer;
outline: none;
transition: color 120ms ease, transform 120ms ease;
-webkit-app-region: no-drag;
}
button.toolbar__version:hover,
button.toolbar__version:focus-visible {
color: var(--text-primary);
}
button.toolbar__version:focus-visible {
text-decoration: underline;
text-underline-offset: 3px;
}
button.toolbar__version:hover {
transform: translateY(-1px);
}
.toolbar__version.is-update-available {
color: var(--text-secondary);
}
.toolbar__version-update-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 9px var(--accent-glow);
flex-shrink: 0;
}
.toolbar__version-number,
.toolbar__version-commit {
overflow: hidden;
text-overflow: ellipsis;
}
.toolbar__version-separator {
color: var(--text-muted);
}
.toolbar__profile {
position: relative;
display: flex;
@@ -561,6 +623,19 @@ select {
transform: none;
}
@media (max-width: 640px) {
.toolbar__version-commit,
.toolbar__version-separator {
display: none;
}
}
@media (max-width: 520px) {
.toolbar__version {
display: none;
}
}
.scope-strip {
position: relative;
width: 100%;
+6
View File
@@ -0,0 +1,6 @@
export interface AppBuildInfo {
version: string
commitHash: string | null
shortCommitHash: string | null
isDirty: boolean
}
+13
View File
@@ -0,0 +1,13 @@
export type UpdateCheckStatus = 'up-to-date' | 'update-available' | 'error'
export interface UpdateCheckResult {
status: UpdateCheckStatus
updateAvailable: boolean
currentVersion: string
latestTag: string | null
latestVersion: string | null
releaseName: string | null
releaseUrl: string
checkedAt: number
message: string
}