Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion electron/ipc/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getUvBinaryPath, getUvEnvVars } from '../lib/uv.js'
import { getHiddenWindowOptions, getUvArchiveName, getVenvPythonPath } from '../lib/platform.js'
import { getServerState, stopServerSync } from '../lib/serverState.js'
import { runUvSyncWithMirroredLogs } from '../lib/uvSync.js'
import { copyServerComponentFiles } from '../lib/serverFiles.js'
import { copyServerComponentFiles, ensureEngineFont } from '../lib/serverFiles.js'
import { emitToAllWindows } from '../lib/ipcUtils.js'

const UV_VERSION = '0.10.9'
Expand Down Expand Up @@ -36,6 +36,9 @@ function unpackServerFilesInner(force: boolean): string {
const hasKey =
fs.existsSync(path.join(engineDir, 'pyproject.toml')) && fs.existsSync(path.join(engineDir, 'server.py'))
if (hasKey) {
// Re-run the font copy so upgrades from older installs (which didn't
// unpack fonts) pick it up without a full reinstall.
ensureEngineFont(engineDir)
return 'Files already exist, skipped unpacking'
}

Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerServerIpc } from './server.js'
import { registerWindowIpc } from './window.js'
import { registerDebugIpc } from './debug.js'
import { registerUpdateIpc } from './update.js'
import { registerRecordingsIpc } from './recordings.js'

export function registerAllIpc(): void {
registerSettingsIpc()
Expand All @@ -18,4 +19,5 @@ export function registerAllIpc(): void {
registerWindowIpc()
registerDebugIpc()
registerUpdateIpc()
registerRecordingsIpc()
}
147 changes: 147 additions & 0 deletions electron/ipc/recordings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { app, BrowserWindow, dialog, ipcMain } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
import open from 'open'
import { parseFile } from 'music-metadata'
import type { RecordingProperties } from '../../src/types/ipc.js'

export type RecordingEntry = {
filename: string
path: string
size_bytes: number
mtime_ms: number
/** Semantic properties parsed from the MP4's `comment` atom (written by
* `_properties_to_mp4_metadata` server-side). `null` when the file
* predates the metadata feature or the atom is absent/unparseable. */
properties: RecordingProperties | null
}

/** Read the `comment` atom from an MP4 and parse it as the JSON blob that
* video_recorder.py writes. Returns `null` on any failure — caller uses
* this to decide whether to show the metadata row in the UI. */
async function readRecordingProperties(filePath: string): Promise<RecordingProperties | null> {
try {
const parsed = await parseFile(filePath, { skipCovers: true, skipPostHeaders: true })
const raw = Array.isArray(parsed.common.comment) ? parsed.common.comment[0] : parsed.common.comment
const text = typeof raw === 'string' ? raw : (raw as { text?: string } | undefined)?.text
if (!text) return null
const obj = JSON.parse(text) as unknown
return obj && typeof obj === 'object' ? (obj as RecordingProperties) : null
} catch {
return null
}
}

/** The currently-configured output directory, cached after the last
* resolve-video-dir / list-recordings call. Used by the `biome-recording://`
* protocol handler, which is stateless itself but needs a dir to look in. */
let currentRecordingsDir: string | null = null

export function getCurrentRecordingsDir(): string | null {
return currentRecordingsDir
}

function getDefaultRecordingsDir(): string {
// app.getPath('videos') resolves to the user's OS-specific video directory:
// Windows → %USERPROFILE%\Videos
// macOS → ~/Movies
// Linux → XDG user-dirs VIDEOS (usually ~/Videos)
return path.join(app.getPath('videos'), 'Biome')
}

function resolveRecordingsDir(configured: string): string {
const trimmed = (configured ?? '').trim()
return trimmed ? path.resolve(trimmed) : getDefaultRecordingsDir()
}

function ensureDir(dir: string): void {
fs.mkdirSync(dir, { recursive: true })
}

function isWithin(child: string, parent: string): boolean {
const rel = path.relative(parent, child)
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
}

/** Launch the OS's default handler for `target` in a fully detached process.
* `open` takes care of the OS-native shell invocation, Windows path-quoting
* edge cases, and detaching + unref'ing so Biome can exit independently. */
function openDetached(target: string): void {
open(target).catch((err) => {
console.error(`[RECORDINGS] Failed to open "${target}":`, err)
})
}

export function registerRecordingsIpc(): void {
ipcMain.handle('get-default-video-dir', () => getDefaultRecordingsDir())

ipcMain.handle('resolve-video-dir', (_event, configured: string) => {
const resolved = resolveRecordingsDir(configured)
ensureDir(resolved)
currentRecordingsDir = resolved
return resolved
})

ipcMain.handle('pick-video-dir', async (_event, currentValue: string) => {
const parentWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
const defaultPath = resolveRecordingsDir(currentValue)
const result = await dialog.showOpenDialog(parentWindow, {
title: 'Choose recordings folder',
defaultPath,
properties: ['openDirectory', 'createDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths[0]
})

ipcMain.handle('list-recordings', async (_event, configured: string): Promise<RecordingEntry[]> => {
const dir = resolveRecordingsDir(configured)
currentRecordingsDir = dir
if (!fs.existsSync(dir)) return []

// First pass: collect filename + stats synchronously.
const candidates: { filename: string; path: string; size_bytes: number; mtime_ms: number }[] = []
for (const name of fs.readdirSync(dir)) {
if (!name.toLowerCase().endsWith('.mp4')) continue
const fullPath = path.join(dir, name)
try {
const stat = fs.statSync(fullPath)
if (!stat.isFile()) continue
candidates.push({ filename: name, path: fullPath, size_bytes: stat.size, mtime_ms: stat.mtimeMs })
} catch {
// skip unreadable entries
}
}

// Second pass: parse metadata in parallel. Each parseFile reads a small
// prefix of the file so this is cheap enough to do every refresh.
const properties = await Promise.all(candidates.map((c) => readRecordingProperties(c.path)))

const results: RecordingEntry[] = candidates.map((c, i) => ({ ...c, properties: properties[i] }))
results.sort((a, b) => b.mtime_ms - a.mtime_ms)
return results
})

ipcMain.handle('delete-recording', (_event, filePath: string) => {
// Only allow deletion within the currently-configured recordings dir —
// refuses arbitrary paths even if the renderer is compromised.
if (!currentRecordingsDir) return
const resolved = path.resolve(filePath)
if (!isWithin(resolved, currentRecordingsDir)) return
fs.rmSync(resolved, { force: true })
})

ipcMain.handle('open-recording-externally', (_event, filePath: string) => {
if (!currentRecordingsDir) return
const resolved = path.resolve(filePath)
if (!isWithin(resolved, currentRecordingsDir)) return
openDetached(resolved)
})

ipcMain.handle('open-recordings-folder', (_event, configured: string) => {
const dir = resolveRecordingsDir(configured)
ensureDir(dir)
currentRecordingsDir = dir
openDetached(dir)
})
}
11 changes: 11 additions & 0 deletions electron/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,14 @@ export function getResourcePath(resourceName: string): string {
// In dev, resources are at the project root
return path.join(app.getAppPath(), resourceName)
}

/**
* Get the path to a bundled font file. In packaged builds, extraResource
* flattens fonts into the resources root; in dev, they live under `assets/`.
*/
export function getBundledFontPath(filename: string): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, filename)
}
return path.join(app.getAppPath(), 'assets', filename)
}
13 changes: 12 additions & 1 deletion electron/lib/serverFiles.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import fs from 'node:fs'
import path from 'node:path'
import { SERVER_COMPONENT_EXCLUDES, getResourcePath } from './paths.js'
import { SERVER_COMPONENT_EXCLUDES, getBundledFontPath, getResourcePath } from './paths.js'

/** Place the bundled Salernomi J font at `<engineDir>/fonts/9SALERNO.TTF` so
* the Python recorder can locate it via `Path(__file__).parent / "fonts"`.
* Called alongside copyServerComponentFiles and again on engine-file checks
* so upgrades from older installs pick up the font without a full reinstall. */
export function ensureEngineFont(engineDir: string): void {
const fontsDir = path.join(engineDir, 'fonts')
fs.mkdirSync(fontsDir, { recursive: true })
fs.copyFileSync(getBundledFontPath('9SALERNO.TTF'), path.join(fontsDir, '9SALERNO.TTF'))
}

/** Recursively copy server-components to the engine directory, skipping excluded entries. */
export function copyServerComponentFiles(engineDir: string): void {
const resourceDir = getResourcePath('server-components')
copyDirRecursive(resourceDir, engineDir, SERVER_COMPONENT_EXCLUDES)
ensureEngineFont(engineDir)
}

function copyDirRecursive(src: string, dest: string, excludes: Set<string>): void {
Expand Down
35 changes: 26 additions & 9 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,17 @@ import fs from 'node:fs'
import { registerAllIpc } from './ipc/index.js'
import { stopServerSync } from './lib/serverState.js'
import { getBackgroundsDir } from './ipc/backgrounds.js'
import { getCurrentRecordingsDir } from './ipc/recordings.js'

// Register biome-bg as a privileged scheme so <video> elements can stream from it.
// Must be called before app.whenReady().
// Register biome-bg / biome-recording as privileged schemes so <video> elements
// can stream from them. Must be called before app.whenReady().
protocol.registerSchemesAsPrivileged([
{
scheme: 'biome-bg',
privileges: { standard: true, supportFetchAPI: true, stream: true, bypassCSP: true }
}
])

// Register biome-bg as a privileged scheme so <video> elements can stream from it.
// Must be called before app.whenReady().
protocol.registerSchemesAsPrivileged([
},
{
scheme: 'biome-bg',
scheme: 'biome-recording',
privileges: { standard: true, supportFetchAPI: true, stream: true, bypassCSP: true }
}
])
Expand Down Expand Up @@ -145,6 +141,27 @@ app
return net.fetch(`file://${filePath}`)
})

// biome-recording://serve/<basename>.mp4 streams a single file from the
// currently-configured recordings dir (set by list-recordings /
// resolve-video-dir). basename-only: refuses anything with path separators.
protocol.handle('biome-recording', (request) => {
const url = new URL(request.url)
const filename = path.basename(url.pathname)
if (!filename || filename !== decodeURIComponent(url.pathname.replace(/^\/+/, ''))) {
return new Response('Not found', { status: 404 })
}

const dir = getCurrentRecordingsDir()
if (!dir) return new Response('Not found', { status: 404 })

const filePath = path.join(dir, filename)
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return new Response('Not found', { status: 404 })
}

return net.fetch(`file://${filePath}`)
})

registerAllIpc()
createWindow()
})
Expand Down
1 change: 1 addition & 0 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const config: ForgeConfig = {
'./seeds',
'./licensing',
'./backgrounds',
'./assets/9SALERNO.TTF',
'./app-icon.ico',
'./app-icon.png'
]
Expand Down
Loading
Loading