diff --git a/package.json b/package.json index 7408a1b..ed0116b 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "Open-source audio metering and visualization tool", "main": "./out/main/index.js", "scripts": { - "dev": "electron-vite dev", - "dev:mac": "env -u ELECTRON_RUN_AS_NODE electron-vite dev", - "build": "electron-vite build", + "dev": "node ./scripts/build/writeAppBuildMetadata.cjs && electron-vite dev", + "dev:mac": "node ./scripts/build/writeAppBuildMetadata.cjs && env -u ELECTRON_RUN_AS_NODE electron-vite dev", + "build": "electron-vite build && node ./scripts/build/writeAppBuildMetadata.cjs", "preview": "electron-vite preview", "typecheck": "tsc --noEmit", "test:audio-router": "node scripts/run-audio-router-tests.mjs", @@ -20,6 +20,8 @@ "test:secret-vault": "node scripts/run-secret-vault-tests.mjs", "test:window-state": "node scripts/run-window-state-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", + "test:build-metadata": "node scripts/run-build-metadata-tests.mjs", + "test:updates": "node scripts/run-update-tests.mjs", "build:native": "cd native && node-gyp rebuild", "rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"", "postinstall": "npm run rebuild:native || echo 'Native build failed, will use JS fallback'", diff --git a/scripts/build/resolveAppBuildMetadata.ts b/scripts/build/resolveAppBuildMetadata.ts new file mode 100644 index 0000000..c52f86d --- /dev/null +++ b/scripts/build/resolveAppBuildMetadata.ts @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process' + +export interface AppBuildMetadata { + commitHash: string | null + isDirty: boolean +} + +interface ResolveAppBuildMetadataOptions { + cwd?: string + env?: NodeJS.ProcessEnv + runGitCommand?: (cwd: string, args: string[]) => string +} + +const DIRTY_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'dirty']) +const DIRTY_ENV_FALSE_VALUES = new Set(['0', 'false', 'no', 'clean']) + +function normalizeCommitHash(value: string | undefined): string | null { + if (typeof value !== 'string') return null + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function parseDirtyEnvValue(value: string | undefined): 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 execGitCommand(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) +} + +function resolveGitCommitHash( + cwd: string, + runGitCommand: (cwd: string, args: string[]) => string, +): string | null { + try { + return normalizeCommitHash(runGitCommand(cwd, ['rev-parse', 'HEAD'])) + } catch { + return null + } +} + +function resolveGitDirtyState(cwd: string, runGitCommand: (cwd: string, args: string[]) => string): boolean { + try { + return runGitCommand(cwd, ['status', '--porcelain']).trim().length > 0 + } catch { + return false + } +} + +export function resolveAppBuildMetadata(options: ResolveAppBuildMetadataOptions = {}): AppBuildMetadata { + const cwd = options.cwd ?? process.cwd() + const env = options.env ?? process.env + const runGitCommand = options.runGitCommand ?? execGitCommand + + const envCommitHash = normalizeCommitHash(env.PRISM_GIT_COMMIT) + const envDirtyState = parseDirtyEnvValue(env.PRISM_GIT_DIRTY) + + if (envCommitHash) { + return { + commitHash: envCommitHash, + isDirty: envDirtyState ?? false, + } + } + + const commitHash = resolveGitCommitHash(cwd, runGitCommand) + if (!commitHash) { + return { + commitHash: null, + isDirty: false, + } + } + + return { + commitHash, + isDirty: envDirtyState ?? resolveGitDirtyState(cwd, runGitCommand), + } +} diff --git a/scripts/build/writeAppBuildMetadata.cjs b/scripts/build/writeAppBuildMetadata.cjs new file mode 100644 index 0000000..3b06523 --- /dev/null +++ b/scripts/build/writeAppBuildMetadata.cjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +const fs = require('fs') +const path = require('path') +const { execFileSync } = require('child_process') + +const outputPath = path.resolve(__dirname, '../../out/build-metadata.json') +const repoRoot = path.resolve(__dirname, '../..') + +const DIRTY_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'dirty']) +const DIRTY_ENV_FALSE_VALUES = new Set(['0', 'false', 'no', 'clean']) + +function normalizeCommitHash(value) { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function parseDirtyEnvValue(value) { + 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 execGit(args) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) +} + +function resolveBuildMetadata() { + const envCommitHash = normalizeCommitHash(process.env.PRISM_GIT_COMMIT) + const envDirty = parseDirtyEnvValue(process.env.PRISM_GIT_DIRTY) + + if (envCommitHash) { + return { + commitHash: envCommitHash, + isDirty: envDirty ?? false, + } + } + + try { + const commitHash = normalizeCommitHash(execGit(['rev-parse', 'HEAD'])) + if (!commitHash) { + return { + commitHash: null, + isDirty: false, + } + } + + const isDirty = envDirty ?? execGit(['status', '--porcelain']).trim().length > 0 + return { + commitHash, + isDirty, + } + } catch { + return { + commitHash: null, + isDirty: false, + } + } +} + +const metadata = resolveBuildMetadata() +fs.mkdirSync(path.dirname(outputPath), { recursive: true }) +fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8') diff --git a/scripts/run-build-metadata-tests.mjs b/scripts/run-build-metadata-tests.mjs new file mode 100644 index 0000000..f935814 --- /dev/null +++ b/scripts/run-build-metadata-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-build-metadata-tests-')) +const bundledTestPath = join(tempDir, 'build-metadata.test.mjs') +const entryPoint = join(rootDir, 'test', 'build-metadata.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/scripts/run-update-tests.mjs b/scripts/run-update-tests.mjs new file mode 100644 index 0000000..863a269 --- /dev/null +++ b/scripts/run-update-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-update-tests-')) +const bundledTestPath = join(tempDir, 'updates.test.mjs') +const entryPoint = join(rootDir, 'test', 'updates.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index a6a08fb..a823ab3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() }) diff --git a/src/main/services/updates.ts b/src/main/services/updates.ts new file mode 100644 index 0000000..c239442 --- /dev/null +++ b/src/main/services/updates.ts @@ -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 +} + +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 { + 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 { + 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}`, + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ba8f337..82cdae1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, 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, migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => ipcRenderer.invoke('themes:migrate-legacy', payload) as Promise, openExternalUrl: (url: string) => ipcRenderer.invoke('shell:open-external', url) as Promise, + updates: { + checkForUpdates: () => ipcRenderer.invoke('updates:check') as Promise, + openReleasesPage: (releaseUrl?: string) => ipcRenderer.invoke('updates:open-releases-page', releaseUrl) as Promise, + }, 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), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6c941f7..35f9ac2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 6358f3a..026e964 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -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 ?