mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
add version in top bar and update checking
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Vendored
+7
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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%;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AppBuildInfo {
|
||||
version: string
|
||||
commitHash: string | null
|
||||
shortCommitHash: string | null
|
||||
isDirty: boolean
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user