This commit is contained in:
Boof2015
2026-03-30 21:46:11 -04:00
parent 6cab2f86ee
commit c1f1b6566c
6 changed files with 242 additions and 5 deletions
+117 -1
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron'
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
import { extname, join, resolve } from 'path'
import type {
AstraControlCommand,
@@ -22,7 +23,7 @@ import type {
ThemeLibrarySnapshot,
} from '../types/theme'
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
import type { PerformanceMemorySnapshot } from '../types/performance'
import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance'
import { normalizeProfile } from '../shared/profileState'
import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary'
@@ -70,6 +71,12 @@ const POPOUT_DEFAULTS = {
minWidth: 220,
minHeight: 160,
}
const MEMORY_LOG_DIRECTORY_NAME = 'Prism Logs'
const MEMORY_LOG_FILE_NAME = 'memory-trace-latest.csv'
const memoryLogSessionStartedAt = Date.now()
let memoryLogInitialized = false
let memoryLogInitialization: Promise<void> | null = null
let memoryLogWriteQueue: Promise<void> = Promise.resolve()
function kilobytesToMegabytes(value: number | undefined): number {
return Math.round((((value ?? 0) / 1024) * 10)) / 10
@@ -90,6 +97,101 @@ function sumWorkingSet(metrics: Electron.ProcessMetric[], predicate: (metric: El
return kilobytesToMegabytes(totalKilobytes)
}
function getMemoryLogDirectory(): string {
return join(app.getPath('documents'), MEMORY_LOG_DIRECTORY_NAME)
}
function getMemoryLogPath(): string {
return join(getMemoryLogDirectory(), MEMORY_LOG_FILE_NAME)
}
function escapeCsvField(value: string | number | boolean | null): string {
if (value === null) {
return ''
}
const text = String(value)
if (!/[",\n]/.test(text)) {
return text
}
return `"${text.replace(/"/g, '""')}"`
}
async function ensureMemoryLogFile(): Promise<void> {
if (memoryLogInitialized) {
return
}
if (!memoryLogInitialization) {
memoryLogInitialization = (async () => {
await mkdir(getMemoryLogDirectory(), { recursive: true })
const header = [
'session_started_at_iso',
'captured_at_iso',
'elapsed_s',
'app_mb',
'main_mb',
'renderer_mb',
'renderer_private_mb',
'gpu_mb',
'utility_mb',
'js_heap_used_mb',
'js_heap_limit_mb',
'renderer_delta_mb',
'app_delta_mb',
'frame_target',
'docked_render_fps',
'is_capturing',
'capture_status',
'visible_scopes',
'popped_out_scopes',
].join(',')
await writeFile(getMemoryLogPath(), `${header}\n`, 'utf8')
memoryLogInitialized = true
})().finally(() => {
memoryLogInitialization = null
})
}
await memoryLogInitialization
}
function queueMemoryLogWrite(record: PerformanceMemoryLogRecord): void {
const line = [
new Date(memoryLogSessionStartedAt).toISOString(),
new Date(record.capturedAt).toISOString(),
record.elapsedSeconds.toFixed(1),
record.appMb.toFixed(1),
record.mainMb.toFixed(1),
record.rendererMb.toFixed(1),
record.rendererPrivateMb === null ? '' : record.rendererPrivateMb.toFixed(1),
record.gpuMb.toFixed(1),
record.utilityMb.toFixed(1),
record.jsHeapUsedMb === null ? '' : record.jsHeapUsedMb.toFixed(1),
record.jsHeapLimitMb === null ? '' : record.jsHeapLimitMb.toFixed(1),
record.rendererDeltaMb.toFixed(1),
record.appDeltaMb.toFixed(1),
record.frameTarget,
Math.round(record.dockedRenderFps),
record.isCapturing,
record.captureStatus,
record.visibleScopes.join('|'),
record.poppedOutScopes.join('|'),
]
.map((field) => escapeCsvField(field))
.join(',')
memoryLogWriteQueue = memoryLogWriteQueue
.then(async () => {
await ensureMemoryLogFile()
await appendFile(getMemoryLogPath(), `${line}\n`, 'utf8')
})
.catch((error: unknown) => {
console.error('Failed to write memory trace log:', error)
})
}
function getProfileLibrary(): FileBackedProfileLibrary {
if (!profileLibrary) {
profileLibrary = new FileBackedProfileLibrary(
@@ -923,6 +1025,16 @@ function setupIPC(): void {
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true
})
ipcMain.handle('performance:get-memory-log-path', async () => {
await ensureMemoryLogFile()
return getMemoryLogPath()
})
ipcMain.handle('performance:reveal-memory-log', async () => {
await ensureMemoryLogFile()
shell.showItemInFolder(getMemoryLogPath())
})
ipcMain.handle('performance:get-memory-snapshot', async (event): Promise<PerformanceMemorySnapshot> => {
const metrics = app.getAppMetrics()
const senderPid = event.sender.getOSProcessId()
@@ -942,6 +1054,10 @@ function setupIPC(): void {
}
})
ipcMain.handle('performance:append-memory-log', async (_event, record: PerformanceMemoryLogRecord) => {
queueMemoryLogWrite(record)
})
ipcMain.handle('audio:get-desktop-sources', async () => {
const sources = await desktopCapturer.getSources({ types: ['screen'] })
return sources.map((s) => ({ id: s.id, name: s.name }))
+6 -1
View File
@@ -28,7 +28,7 @@ import type {
} from '../types/theme'
import type { ResizeDirection } from '../types/windowResize'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
import type { PerformanceMemorySnapshot } from '../types/performance'
import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance'
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI
@@ -50,6 +50,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position),
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
getPerformanceMemoryLogPath: () => ipcRenderer.invoke('performance:get-memory-log-path') as Promise<string>,
revealPerformanceMemoryLog: () => ipcRenderer.invoke('performance:reveal-memory-log') as Promise<void>,
appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => {
return ipcRenderer.invoke('performance:append-memory-log', record) as Promise<void>
},
getPerformanceMemorySnapshot: async () => {
const snapshot = await ipcRenderer.invoke('performance:get-memory-snapshot') as PerformanceMemorySnapshot
try {
+32
View File
@@ -40,6 +40,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
const rootRef = useRef<HTMLDivElement | null>(null)
const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('')
const [astraTokenInput, setAstraTokenInput] = useState('')
const [memoryLogPath, setMemoryLogPath] = useState('')
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
@@ -95,6 +96,26 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
setAstraTokenInput(astraState.config.token)
}, [astraState.config.baseUrl, astraState.config.token])
useEffect(() => {
let disposed = false
void (async () => {
try {
const path = await window.electronAPI.getPerformanceMemoryLogPath()
if (!disposed) {
setMemoryLogPath(path)
}
} catch {
if (!disposed) {
setMemoryLogPath('')
}
}
})()
return () => {
disposed = true
}
}, [])
useLayoutEffect(() => {
if (!onHeightChange || !rootRef.current) return
@@ -440,6 +461,17 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="settings-status-pill bottom-bar__fps-pill" title="Docked visualizer render FPS">
<span>{roundedDockedRenderFps} FPS</span>
</div>
<button
type="button"
className="settings-chip"
title={memoryLogPath || 'Reveal the latest memory trace CSV'}
onClick={() => {
void window.electronAPI.revealPerformanceMemoryLog()
}}
>
Log File
</button>
</div>
{memorySample ? (
+4 -1
View File
@@ -29,7 +29,7 @@ import type {
ThemeLibrarySnapshot,
} from '../types/theme'
import type { ResizeDirection } from '../types/windowResize'
import type { PerformanceMemorySnapshot } from '../types/performance'
import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance'
declare global {
interface Window {
@@ -48,6 +48,9 @@ declare global {
repositionWindow: (position: 'top' | 'bottom') => void
toggleAlwaysOnTop: () => void
isAlwaysOnTop: () => Promise<boolean>
getPerformanceMemoryLogPath: () => Promise<string>
revealPerformanceMemoryLog: () => Promise<void>
appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => Promise<void>
getPerformanceMemorySnapshot: () => Promise<PerformanceMemorySnapshot>
getDesktopSources: () => Promise<{ id: string; name: string }[]>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
+71 -2
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import {
isVisualizerFrameTarget,
type PerformanceMemoryLogRecord,
type PerformanceMemorySnapshot,
type VisualizerFrameTarget,
} from '../../types/performance'
@@ -88,6 +89,17 @@ interface ChromiumPerformanceMemory {
let memoryMonitorTimer: ReturnType<typeof setInterval> | null = null
let memoryMonitorRefCount = 0
let memoryMonitorInFlight = false
let contextStoreAccessorsPromise: Promise<{
getAudioState: () => {
isCapturing: boolean
captureStatus: 'idle' | 'connecting' | 'capturing' | 'error'
}
getSettingsState: () => {
scopeOrder: string[]
hiddenScopes: Set<string>
scopePopouts: Record<string, { poppedOut?: boolean }>
}
}> | null = null
function isMainWindowContext(): boolean {
if (typeof window === 'undefined') {
@@ -117,6 +129,34 @@ async function collectMemorySnapshot(): Promise<PerformanceMemorySnapshot | null
}
}
async function getContextStoreAccessors(): Promise<{
getAudioState: () => {
isCapturing: boolean
captureStatus: 'idle' | 'connecting' | 'capturing' | 'error'
}
getSettingsState: () => {
scopeOrder: string[]
hiddenScopes: Set<string>
scopePopouts: Record<string, { poppedOut?: boolean }>
}
} | null> {
if (typeof window === 'undefined') {
return null
}
if (!contextStoreAccessorsPromise) {
contextStoreAccessorsPromise = Promise.all([
import('./audioStore'),
import('./settingsStore'),
]).then(([audioStoreModule, settingsStoreModule]) => ({
getAudioState: () => audioStoreModule.useAudioStore.getState(),
getSettingsState: () => settingsStoreModule.useSettingsStore.getState(),
}))
}
return contextStoreAccessorsPromise
}
async function sampleMemory(): Promise<void> {
if (memoryMonitorInFlight) {
return
@@ -129,6 +169,8 @@ async function sampleMemory(): Promise<void> {
return
}
const contextStoreAccessors = await getContextStoreAccessors()
let logRecord: PerformanceMemoryLogRecord | null = null
usePerformanceStore.setState((state) => {
const baseline = state.memoryHistory[0] ?? snapshot
const nextHistory = [...state.memoryHistory, snapshot]
@@ -138,14 +180,41 @@ async function sampleMemory(): Promise<void> {
const baselineRendererMb = baseline.rendererPrivateMb ?? baseline.rendererMb
const currentRendererMb = snapshot.rendererPrivateMb ?? snapshot.rendererMb
const rendererDeltaMb = Math.round((currentRendererMb - baselineRendererMb) * 10) / 10
const appDeltaMb = Math.round((snapshot.appMb - baseline.appMb) * 10) / 10
const settingsState = contextStoreAccessors?.getSettingsState()
const audioState = contextStoreAccessors?.getAudioState()
const visibleScopes = settingsState
? settingsState.scopeOrder.filter((kind) => !settingsState.hiddenScopes.has(kind))
: []
const poppedOutScopes = settingsState
? settingsState.scopeOrder.filter((kind) => settingsState.scopePopouts[kind]?.poppedOut)
: []
logRecord = {
...snapshot,
elapsedSeconds: Math.round(((snapshot.capturedAt - baseline.capturedAt) / 100)) / 10,
rendererDeltaMb,
appDeltaMb,
frameTarget: state.frameTarget,
dockedRenderFps: state.dockedRenderFps,
isCapturing: audioState?.isCapturing ?? false,
captureStatus: audioState?.captureStatus ?? 'idle',
visibleScopes,
poppedOutScopes,
}
return {
memorySample: snapshot,
memoryHistory: nextHistory,
rendererMemoryDeltaMb: Math.round((currentRendererMb - baselineRendererMb) * 10) / 10,
appMemoryDeltaMb: Math.round((snapshot.appMb - baseline.appMb) * 10) / 10,
rendererMemoryDeltaMb: rendererDeltaMb,
appMemoryDeltaMb: appDeltaMb,
}
})
if (logRecord && typeof window !== 'undefined' && typeof window.electronAPI?.appendPerformanceMemoryLog === 'function') {
void window.electronAPI.appendPerformanceMemoryLog(logRecord)
}
} finally {
memoryMonitorInFlight = false
}
+12
View File
@@ -14,6 +14,18 @@ export interface PerformanceMemorySnapshot {
jsHeapLimitMb: number | null
}
export interface PerformanceMemoryLogRecord extends PerformanceMemorySnapshot {
elapsedSeconds: number
rendererDeltaMb: number
appDeltaMb: number
frameTarget: VisualizerFrameTarget
dockedRenderFps: number
isCapturing: boolean
captureStatus: 'idle' | 'connecting' | 'capturing' | 'error'
visibleScopes: string[]
poppedOutScopes: string[]
}
export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget {
return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget)
}