diff --git a/package.json b/package.json index ed0116b..b62ad23 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "electron-vite build && node ./scripts/build/writeAppBuildMetadata.cjs", "preview": "electron-vite preview", "typecheck": "tsc --noEmit", + "build:icons": "node scripts/build/generate-prism-icons.mjs", "test:audio-router": "node scripts/run-audio-router-tests.mjs", "test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs", @@ -85,10 +86,15 @@ "from": "native/build/Release/", "to": "native/", "filter": ["*.node"] + }, + { + "from": "resources/icon.png", + "to": "icon.png" } ], "mac": { "target": ["dmg", "zip"], + "icon": "resources/icon.icns", "category": "public.app-category.music", "hardenedRuntime": true, "entitlements": "resources/entitlements.mac.plist", @@ -98,10 +104,12 @@ "target": [ { "target": "nsis", "arch": ["x64"] }, { "target": "portable", "arch": ["x64"] } - ] + ], + "icon": "resources/icon.ico" }, "linux": { "target": ["AppImage", "deb"], + "icon": "resources/icon.png", "category": "Audio" } } diff --git a/resources/Prism logo.svg b/resources/Prism logo.svg new file mode 100644 index 0000000..72416c1 --- /dev/null +++ b/resources/Prism logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icon.icns b/resources/icon.icns new file mode 100644 index 0000000..9c134c5 Binary files /dev/null and b/resources/icon.icns differ diff --git a/resources/icon.ico b/resources/icon.ico new file mode 100644 index 0000000..bafc186 Binary files /dev/null and b/resources/icon.ico differ diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..c5ee387 Binary files /dev/null and b/resources/icon.png differ diff --git a/scripts/build/generate-prism-icons.mjs b/scripts/build/generate-prism-icons.mjs new file mode 100644 index 0000000..07e34fe --- /dev/null +++ b/scripts/build/generate-prism-icons.mjs @@ -0,0 +1,337 @@ +import { execFileSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { deflateSync, inflateSync } from 'node:zlib' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(scriptDir, '../..') +const resourcesDir = join(repoRoot, 'resources') +const tempDir = mkdtempSync(join(tmpdir(), 'prism-icons-')) +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) +const iconBackground = { r: 5, g: 7, b: 10 } +const iconCanvasSize = 1024 +const iconBackgroundInsetRatio = 64 / 1024 +const iconCornerRadiusRatio = 0.22 +const iconSymbolScale = 2.12 +const prismLogoCenter = { x: 337.5, y: 270 } + +const lowerPath = 'M429.005,240.019c1.742,-1.006 3.718,-1.535 5.729,-1.535c5.527,0 17.412,0 26.401,0c6.328,0 11.459,5.13 11.459,11.459l0,47.969c0,4.094 -2.184,7.877 -5.729,9.923c-29.927,17.278 -167.716,96.831 -188.613,108.895c-1.742,1.006 -3.718,1.535 -5.729,1.535c-9.919,0 -41.456,0 -58.657,0c-6.328,-0 -11.459,-5.13 -11.459,-11.459c0,-9.188 0,-21.759 0,-29.346c-0,-4.094 2.184,-7.877 5.729,-9.923c32.974,-19.038 197.892,-114.253 220.869,-127.519Z' +const upperPath = 'M266.17,121.735c2.011,-0 3.987,0.529 5.729,1.535c17.748,10.247 119.001,68.705 140.796,81.288c2.558,1.477 4.133,4.206 4.133,7.159c0,0.256 0,0.511 0,0.762c0,2.094 -1.121,4.029 -2.938,5.07c-10.641,6.1 -47.292,27.109 -61.53,35.27c-3.541,2.03 -7.893,2.023 -11.428,-0.018c-23.117,-13.347 -109.643,-63.302 -132.798,-76.671c-3.545,-2.047 -5.729,-5.83 -5.729,-9.924c-0,-8.35 -0,-22.847 -0,-33.013c0,-6.328 5.13,-11.459 11.459,-11.459c15.744,0 43.157,0 52.305,0Z' + +function ensureCommand(command) { + try { + execFileSync('which', [command], { stdio: 'ignore' }) + } catch { + throw new Error(`Missing required command: ${command}`) + } +} + +function resizePng(sourcePath, outputPath, size) { + execFileSync('sips', ['-z', String(size), String(size), sourcePath, '--out', outputPath], { + stdio: 'ignore', + }) +} + +function makeCrc32Table() { + const table = new Uint32Array(256) + for (let i = 0; i < table.length; i += 1) { + let value = i + for (let bit = 0; bit < 8; bit += 1) { + value = (value & 1) !== 0 + ? 0xedb88320 ^ (value >>> 1) + : value >>> 1 + } + table[i] = value >>> 0 + } + return table +} + +const crc32Table = makeCrc32Table() + +function crc32(buffer) { + let crc = 0xffffffff + for (const byte of buffer) { + crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function readPngRgba(filePath) { + const buffer = readFileSync(filePath) + if (!buffer.subarray(0, pngSignature.length).equals(pngSignature)) { + throw new Error(`${filePath} is not a PNG file.`) + } + + let offset = pngSignature.length + let width = 0 + let height = 0 + let bitDepth = 0 + let colorType = 0 + const idatChunks = [] + + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset) + const type = buffer.toString('ascii', offset + 4, offset + 8) + const dataStart = offset + 8 + const dataEnd = dataStart + length + const data = buffer.subarray(dataStart, dataEnd) + + if (type === 'IHDR') { + width = data.readUInt32BE(0) + height = data.readUInt32BE(4) + bitDepth = data.readUInt8(8) + colorType = data.readUInt8(9) + } else if (type === 'IDAT') { + idatChunks.push(data) + } else if (type === 'IEND') { + break + } + + offset = dataEnd + 4 + } + + if (bitDepth !== 8 || colorType !== 6) { + throw new Error(`Unsupported PNG format in ${filePath}; expected 8-bit RGBA.`) + } + + const bytesPerPixel = 4 + const stride = width * bytesPerPixel + const inflated = inflateSync(Buffer.concat(idatChunks)) + const pixels = Buffer.alloc(width * height * bytesPerPixel) + let inputOffset = 0 + + for (let y = 0; y < height; y += 1) { + const filter = inflated[inputOffset] + inputOffset += 1 + const row = inflated.subarray(inputOffset, inputOffset + stride) + inputOffset += stride + const outputOffset = y * stride + const previousOffset = (y - 1) * stride + + for (let x = 0; x < stride; x += 1) { + const raw = row[x] + const left = x >= bytesPerPixel ? pixels[outputOffset + x - bytesPerPixel] : 0 + const up = y > 0 ? pixels[previousOffset + x] : 0 + const upLeft = y > 0 && x >= bytesPerPixel ? pixels[previousOffset + x - bytesPerPixel] : 0 + let value = raw + + if (filter === 1) { + value = raw + left + } else if (filter === 2) { + value = raw + up + } else if (filter === 3) { + value = raw + Math.floor((left + up) / 2) + } else if (filter === 4) { + const predictor = left + up - upLeft + const pa = Math.abs(predictor - left) + const pb = Math.abs(predictor - up) + const pc = Math.abs(predictor - upLeft) + const paeth = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft + value = raw + paeth + } else if (filter !== 0) { + throw new Error(`Unsupported PNG filter ${filter} in ${filePath}.`) + } + + pixels[outputOffset + x] = value & 0xff + } + } + + return { width, height, pixels } +} + +function writePngRgba(filePath, width, height, pixels) { + const bytesPerPixel = 4 + const stride = width * bytesPerPixel + const raw = Buffer.alloc((stride + 1) * height) + + for (let y = 0; y < height; y += 1) { + const outputOffset = y * (stride + 1) + raw[outputOffset] = 0 + pixels.copy(raw, outputOffset + 1, y * stride, (y + 1) * stride) + } + + function chunk(type, data) { + const typeBuffer = Buffer.from(type, 'ascii') + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0) + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) + } + + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr.writeUInt8(8, 8) + ihdr.writeUInt8(6, 9) + ihdr.writeUInt8(0, 10) + ihdr.writeUInt8(0, 11) + ihdr.writeUInt8(0, 12) + + writeFileSync(filePath, Buffer.concat([ + pngSignature, + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ])) +} + +function roundedRectCoverage(x, y, width, height, radius) { + const clampedX = Math.max(radius, Math.min(width - radius, x)) + const clampedY = Math.max(radius, Math.min(height - radius, y)) + const dx = x - clampedX + const dy = y - clampedY + return dx * dx + dy * dy <= radius * radius ? 1 : 0 +} + +function antiAliasedRoundedRectCoverage(pixelX, pixelY, width, height, radius) { + const samplesPerAxis = 4 + let covered = 0 + + for (let sampleY = 0; sampleY < samplesPerAxis; sampleY += 1) { + for (let sampleX = 0; sampleX < samplesPerAxis; sampleX += 1) { + const x = pixelX + ((sampleX + 0.5) / samplesPerAxis) + const y = pixelY + ((sampleY + 0.5) / samplesPerAxis) + covered += roundedRectCoverage(x, y, width, height, radius) + } + } + + return covered / (samplesPerAxis * samplesPerAxis) +} + +function restoreIconBackgroundAlpha(filePath) { + const image = readPngRgba(filePath) + const { width, height, pixels } = image + const inset = Math.round(Math.min(width, height) * iconBackgroundInsetRatio) + const backgroundWidth = width - (inset * 2) + const backgroundHeight = height - (inset * 2) + const radius = Math.min(backgroundWidth, backgroundHeight) * iconCornerRadiusRatio + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const coverage = antiAliasedRoundedRectCoverage( + x - inset, + y - inset, + backgroundWidth, + backgroundHeight, + radius, + ) + if (coverage >= 1) continue + + const offset = ((y * width) + x) * 4 + const alpha = Math.round(coverage * 255) + pixels[offset] = iconBackground.r + pixels[offset + 1] = iconBackground.g + pixels[offset + 2] = iconBackground.b + pixels[offset + 3] = alpha + } + } + + writePngRgba(filePath, width, height, pixels) +} + +function writeIcoFile(outputPath, images) { + const headerLength = 6 + const entryLength = 16 + const directoryLength = headerLength + (images.length * entryLength) + const header = Buffer.alloc(directoryLength) + + header.writeUInt16LE(0, 0) + header.writeUInt16LE(1, 2) + header.writeUInt16LE(images.length, 4) + + let imageOffset = directoryLength + images.forEach(({ size, buffer }, index) => { + const offset = headerLength + (index * entryLength) + header.writeUInt8(size >= 256 ? 0 : size, offset) + header.writeUInt8(size >= 256 ? 0 : size, offset + 1) + header.writeUInt8(0, offset + 2) + header.writeUInt8(0, offset + 3) + header.writeUInt16LE(1, offset + 4) + header.writeUInt16LE(32, offset + 6) + header.writeUInt32LE(buffer.length, offset + 8) + header.writeUInt32LE(imageOffset, offset + 12) + imageOffset += buffer.length + }) + + writeFileSync(outputPath, Buffer.concat([header, ...images.map((image) => image.buffer)])) +} + +try { + ensureCommand('qlmanage') + ensureCommand('sips') + ensureCommand('iconutil') + + mkdirSync(resourcesDir, { recursive: true }) + + const sourceSvgPath = join(tempDir, 'prism-icon-source.svg') + const backgroundInset = iconCanvasSize * iconBackgroundInsetRatio + const backgroundSize = iconCanvasSize - (backgroundInset * 2) + const backgroundRadius = backgroundSize * iconCornerRadiusRatio + const symbolTranslateX = (iconCanvasSize / 2) - (prismLogoCenter.x * iconSymbolScale) + const symbolTranslateY = (iconCanvasSize / 2) - (prismLogoCenter.y * iconSymbolScale) + writeFileSync(sourceSvgPath, ` + + + + + + +`) + + execFileSync('qlmanage', ['-t', '-s', '1024', '-o', tempDir, sourceSvgPath], { + stdio: 'ignore', + }) + + const masterPngPath = `${sourceSvgPath}.png` + if (!existsSync(masterPngPath)) { + throw new Error('Quick Look did not create the source PNG.') + } + + const resourcePngPath = join(resourcesDir, 'icon.png') + copyFileSync(masterPngPath, resourcePngPath) + restoreIconBackgroundAlpha(resourcePngPath) + + const iconsetDir = join(tempDir, 'Prism.iconset') + mkdirSync(iconsetDir) + const iconsetEntries = [ + ['icon_16x16.png', 16], + ['icon_16x16@2x.png', 32], + ['icon_32x32.png', 32], + ['icon_32x32@2x.png', 64], + ['icon_128x128.png', 128], + ['icon_128x128@2x.png', 256], + ['icon_256x256.png', 256], + ['icon_256x256@2x.png', 512], + ['icon_512x512.png', 512], + ['icon_512x512@2x.png', 1024], + ] + + for (const [filename, size] of iconsetEntries) { + resizePng(resourcePngPath, join(iconsetDir, filename), size) + } + + execFileSync('iconutil', ['-c', 'icns', iconsetDir, '-o', join(resourcesDir, 'icon.icns')], { + stdio: 'ignore', + }) + + const icoImages = [] + for (const size of [16, 32, 48, 64, 128, 256]) { + const outputPath = join(tempDir, `icon-${size}.png`) + resizePng(resourcePngPath, outputPath, size) + icoImages.push({ size, buffer: readFileSync(outputPath) }) + } + writeIcoFile(join(resourcesDir, 'icon.ico'), icoImages) +} finally { + rmSync(tempDir, { recursive: true, force: true }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 49b16fb..8192a7f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, safeStorage, screen, session, shell } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, 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' @@ -95,6 +95,7 @@ const NOW_PLAYING_CONFIG_DEFAULTS = { minHeight: 520, } +const STATIC_APP_ICON_FILENAME = 'icon.png' const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180 const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64 const runtimeWindowCapabilities = resolveWindowCapabilities({ @@ -286,6 +287,39 @@ function getWindowStateStore(): FileBackedWindowStateStore { return windowStateStore } +function getStaticAppIconPath(): string | undefined { + const candidates = app.isPackaged + ? [join(process.resourcesPath, STATIC_APP_ICON_FILENAME)] + : [ + join(process.cwd(), 'resources', STATIC_APP_ICON_FILENAME), + join(__dirname, '../../resources', STATIC_APP_ICON_FILENAME), + ] + + return candidates.find((candidate) => existsSync(candidate)) +} + +function getStaticWindowIconOptions(): Pick { + if (process.platform === 'darwin') { + return {} + } + + const icon = getStaticAppIconPath() + return icon ? { icon } : {} +} + +function applyStaticDockIcon(): void { + if (process.platform !== 'darwin') { + return + } + + const iconPath = getStaticAppIconPath() + if (!iconPath) return + + const icon = nativeImage.createFromPath(iconPath) + if (icon.isEmpty()) return + app.dock?.setIcon(icon) +} + function broadcastNowPlayingState(state: NowPlayingState): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed() || window.webContents.isDestroyed()) continue @@ -895,6 +929,7 @@ async function showCustomDialog(options: DialogOptions): Promise { hasShadow: false, skipTaskbar: true, show: false, + ...getStaticWindowIconOptions(), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, @@ -936,6 +971,7 @@ function createMainWindow(): void { maximizable: true, fullscreenable: true, title: 'Prism', + ...getStaticWindowIconOptions(), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, @@ -1105,6 +1141,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro title: `Prism ${SCOPE_LABELS[kind]}`, alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind), show: false, + ...getStaticWindowIconOptions(), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, @@ -1271,6 +1308,7 @@ function createNowPlayingConfigWindow(): BrowserWindow { autoHideMenuBar: true, title: 'Prism Now Playing', show: false, + ...getStaticWindowIconOptions(), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, @@ -1827,6 +1865,7 @@ if (!hasSingleInstanceLock) { setupIPC() await getWindowStateStore().initialize() await syncNativeThemeAppearance() + applyStaticDockIcon() createMainWindow() queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) void processPendingProfileOpenPaths() diff --git a/src/renderer/components/PrismLogo.tsx b/src/renderer/components/PrismLogo.tsx new file mode 100644 index 0000000..75af4b1 --- /dev/null +++ b/src/renderer/components/PrismLogo.tsx @@ -0,0 +1,26 @@ +import type { JSX } from 'react' + +interface PrismLogoProps { + className?: string +} + +export default function PrismLogo({ className }: PrismLogoProps): JSX.Element { + return ( + + ) +} diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 026e964..d7a4c4c 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -4,6 +4,7 @@ import { useSettingsStore } from '../stores/settingsStore' import { useUpdateStore } from '../stores/updateStore' import { useUiStore } from '../stores/uiStore' import { getRendererWindowCapabilities } from '../windowCapabilities' +import PrismLogo from './PrismLogo' function SettingsIcon(): JSX.Element { return ( @@ -81,6 +82,7 @@ interface ToolbarProps { } const DEFAULT_PROFILE_ID = 'profile_default' +const PRISM_SUPPORT_URL = 'https://ko-fi.com/boof2015' const WAYLAND_REPOSITION_UNAVAILABLE_MESSAGE = 'Top/bottom repositioning is unavailable on native Wayland.' function isToolbarInteractiveTarget(target: EventTarget | null): boolean { @@ -409,6 +411,16 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): void openReleasesPage() }, [openReleasesPage]) + const handleOpenSupport = useCallback(() => { + void window.electronAPI.openExternalUrl(PRISM_SUPPORT_URL).catch((error) => { + showBanner({ + tone: 'error', + message: getErrorMessage(error, 'Could not open Ko-fi.'), + actions: [], + }) + }) + }, [showBanner]) + const handleReposition = useCallback((position: 'top' | 'bottom') => { if (!supportsProgrammaticReposition) { return @@ -495,12 +507,18 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): -
- - Prism -
+ + + +