Skip to content

Commit 179c109

Browse files
committed
fix(desktop): bundle slim Windows GStreamer runtime
Bundle the display GStreamer runtime for Windows desktop packages and prune its DLL closure so packaged builds do not depend on a system install.
1 parent cf06c1f commit 179c109

4 files changed

Lines changed: 197 additions & 16 deletions

File tree

apps/desktop/AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,10 @@ Ports / hosts during dev come from the same `config.toml` the rest of the
400400
stack reads (via `@memohai/config`). The repo-level `mise run desktop:dev`
401401
task is the recommended entrypoint when contributing.
402402

403+
Packaged macOS and Windows x64 builds prepare and include the display
404+
GStreamer runtime under `Resources/gstreamer`. Linux builds currently rely on
405+
system GStreamer when available.
406+
403407
## Bundled CLI
404408

405409
The Memoh CLI (Go, source at `cmd/memoh/`) ships inside the desktop

apps/desktop/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ pnpm --filter @memohai/desktop build:dir # unpacked app dir (CI smoke test
3939

4040
Output goes to `apps/desktop/dist/`.
4141

42+
Packaged macOS and Windows x64 builds include the display GStreamer runtime used
43+
by the local server. Linux builds continue to use system GStreamer when
44+
available.
45+
4246
## Icons
4347

4448
All app icons are generated from `apps/web/public/logo.svg` (the brand mark) by

apps/desktop/scripts/build.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function resolveGStreamerTarget(target) {
3535
if (target.startsWith('darwin-')) {
3636
return 'darwin-universal'
3737
}
38-
if (target === 'win32-x64' && process.env.GSTREAMER_ENABLE_WINDOWS_BUNDLE) {
38+
if (target === 'win32-x64') {
3939
return 'win32-x64'
4040
}
4141
return '__none__'

apps/desktop/scripts/prepare-gstreamer.mjs

Lines changed: 188 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const displayInspectionElements = [
6262

6363
const runtimeProfile = `display-${createHash('sha256')
6464
.update(JSON.stringify({
65+
layout: 2,
6566
packages: [...macOSRuntimePackages].sort(),
6667
plugins: [...displayPluginNames].sort(),
6768
elements: [...displayInspectionElements].sort(),
@@ -92,7 +93,7 @@ function currentTarget() {
9293
if (process.platform === 'darwin') {
9394
return 'darwin-universal'
9495
}
95-
if (process.platform === 'win32' && process.arch === 'x64' && process.env.GSTREAMER_ENABLE_WINDOWS_BUNDLE) {
96+
if (process.platform === 'win32' && process.arch === 'x64') {
9697
return 'win32-x64'
9798
}
9899
return null
@@ -347,6 +348,23 @@ function syncDarwinSymlinkClosure(libDir, neededLibraries) {
347348
}
348349
}
349350

351+
function displayRuntimeSeedPaths(targetDir, spec, pluginExtension) {
352+
const pluginDir = resolve(targetDir, 'lib', 'gstreamer-1.0')
353+
const seedPaths = [
354+
resolve(targetDir, spec.binary),
355+
resolve(targetDir, spec.inspect),
356+
resolve(targetDir, spec.scanner),
357+
]
358+
if (existsSync(pluginDir)) {
359+
for (const pluginName of readdirSync(pluginDir)) {
360+
if (pluginName.endsWith(pluginExtension)) {
361+
seedPaths.push(resolve(pluginDir, pluginName))
362+
}
363+
}
364+
}
365+
return seedPaths
366+
}
367+
350368
function collectDarwinLibraryDependencies(targetDir, seedPaths) {
351369
const libDir = resolve(targetDir, 'lib')
352370
const neededLibraries = new Set()
@@ -382,20 +400,7 @@ function pruneDarwinLibraries(targetDir, spec) {
382400
}
383401

384402
const libDir = resolve(targetDir, 'lib')
385-
const pluginDir = resolve(libDir, 'gstreamer-1.0')
386-
const seedPaths = [
387-
resolve(targetDir, spec.binary),
388-
resolve(targetDir, spec.inspect),
389-
resolve(targetDir, spec.scanner),
390-
]
391-
if (existsSync(pluginDir)) {
392-
for (const pluginName of readdirSync(pluginDir)) {
393-
if (pluginName.endsWith('.dylib')) {
394-
seedPaths.push(resolve(pluginDir, pluginName))
395-
}
396-
}
397-
}
398-
403+
const seedPaths = displayRuntimeSeedPaths(targetDir, spec, '.dylib')
399404
const neededLibraries = collectDarwinLibraryDependencies(targetDir, seedPaths)
400405
for (const entry of readdirSync(libDir)) {
401406
if (entry === 'gstreamer-1.0') {
@@ -412,6 +417,173 @@ function pruneDarwinLibraries(targetDir, spec) {
412417
}
413418
}
414419

420+
function readCString(buffer, offset) {
421+
if (offset < 0 || offset >= buffer.length) {
422+
return null
423+
}
424+
let end = offset
425+
while (end < buffer.length && buffer[end] !== 0) {
426+
end += 1
427+
}
428+
return buffer.subarray(offset, end).toString('ascii')
429+
}
430+
431+
function parsePEImage(buffer) {
432+
if (buffer.length < 0x40 || buffer.toString('ascii', 0, 2) !== 'MZ') {
433+
return null
434+
}
435+
const peOffset = buffer.readUInt32LE(0x3c)
436+
if (peOffset + 24 > buffer.length || buffer.readUInt32LE(peOffset) !== 0x00004550) {
437+
return null
438+
}
439+
440+
const sectionCount = buffer.readUInt16LE(peOffset + 6)
441+
const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20)
442+
const optionalHeaderOffset = peOffset + 24
443+
if (optionalHeaderOffset + optionalHeaderSize > buffer.length) {
444+
return null
445+
}
446+
447+
const optionalMagic = buffer.readUInt16LE(optionalHeaderOffset)
448+
const dataDirectoryOffset = optionalHeaderOffset + (
449+
optionalMagic === 0x20b ? 112 : optionalMagic === 0x10b ? 96 : 0
450+
)
451+
if (dataDirectoryOffset === optionalHeaderOffset || dataDirectoryOffset + 14 * 8 > buffer.length) {
452+
return null
453+
}
454+
455+
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize
456+
const sections = []
457+
for (let index = 0; index < sectionCount; index += 1) {
458+
const sectionOffset = sectionTableOffset + index * 40
459+
if (sectionOffset + 40 > buffer.length) {
460+
break
461+
}
462+
sections.push({
463+
virtualSize: buffer.readUInt32LE(sectionOffset + 8),
464+
virtualAddress: buffer.readUInt32LE(sectionOffset + 12),
465+
rawSize: buffer.readUInt32LE(sectionOffset + 16),
466+
rawOffset: buffer.readUInt32LE(sectionOffset + 20),
467+
})
468+
}
469+
470+
return {
471+
dataDirectoryOffset,
472+
rvaToOffset(rva) {
473+
for (const section of sections) {
474+
const size = Math.max(section.virtualSize, section.rawSize)
475+
if (rva >= section.virtualAddress && rva < section.virtualAddress + size) {
476+
const offset = section.rawOffset + rva - section.virtualAddress
477+
return offset >= 0 && offset < buffer.length ? offset : null
478+
}
479+
}
480+
return rva >= 0 && rva < buffer.length ? rva : null
481+
},
482+
}
483+
}
484+
485+
function descriptorIsEmpty(buffer, offset, size) {
486+
for (let index = 0; index < size; index += 4) {
487+
if (buffer.readUInt32LE(offset + index) !== 0) {
488+
return false
489+
}
490+
}
491+
return true
492+
}
493+
494+
function collectPEImportNames(filePath) {
495+
const buffer = readFileSync(filePath)
496+
const image = parsePEImage(buffer)
497+
if (!image) {
498+
return []
499+
}
500+
501+
const names = new Set()
502+
const readDirectory = (directoryIndex, descriptorSize, nameRvaOffset) => {
503+
const directoryOffset = image.dataDirectoryOffset + directoryIndex * 8
504+
const rva = buffer.readUInt32LE(directoryOffset)
505+
const size = buffer.readUInt32LE(directoryOffset + 4)
506+
if (!rva || !size) {
507+
return
508+
}
509+
510+
let descriptorOffset = image.rvaToOffset(rva)
511+
if (descriptorOffset === null) {
512+
return
513+
}
514+
const descriptorEnd = Math.min(buffer.length, descriptorOffset + size)
515+
while (descriptorOffset + descriptorSize <= descriptorEnd) {
516+
if (descriptorIsEmpty(buffer, descriptorOffset, descriptorSize)) {
517+
break
518+
}
519+
const nameRva = buffer.readUInt32LE(descriptorOffset + nameRvaOffset)
520+
const nameOffset = image.rvaToOffset(nameRva)
521+
const name = nameOffset === null ? null : readCString(buffer, nameOffset)
522+
if (name) {
523+
names.add(name)
524+
}
525+
descriptorOffset += descriptorSize
526+
}
527+
}
528+
529+
readDirectory(1, 20, 12)
530+
readDirectory(13, 32, 4)
531+
return [...names]
532+
}
533+
534+
function collectWindowsLibraryDependencies(targetDir, seedPaths) {
535+
const binDir = resolve(targetDir, 'bin')
536+
const availableLibraries = new Map()
537+
for (const entry of readdirSync(binDir)) {
538+
if (entry.toLowerCase().endsWith('.dll')) {
539+
availableLibraries.set(entry.toLowerCase(), entry)
540+
}
541+
}
542+
543+
const neededLibraries = new Set()
544+
const queue = seedPaths.filter(existsSync)
545+
for (let index = 0; index < queue.length; index += 1) {
546+
const binaryPath = queue[index]
547+
let imports
548+
try {
549+
imports = collectPEImportNames(binaryPath)
550+
} catch (error) {
551+
console.warn(
552+
`Could not inspect Windows GStreamer dependencies for ${basename(binaryPath)}: ${formatDownloadError(error)}`,
553+
)
554+
continue
555+
}
556+
for (const dependency of imports) {
557+
const libraryName = availableLibraries.get(dependency.toLowerCase())
558+
if (!libraryName || neededLibraries.has(libraryName)) {
559+
continue
560+
}
561+
neededLibraries.add(libraryName)
562+
queue.push(resolve(binDir, libraryName))
563+
}
564+
}
565+
return neededLibraries
566+
}
567+
568+
function pruneWindowsLibraries(targetDir, spec) {
569+
if (process.env.GSTREAMER_KEEP_ALL_LIBS || process.platform !== 'win32') {
570+
return
571+
}
572+
573+
const binDir = resolve(targetDir, 'bin')
574+
if (!existsSync(binDir)) {
575+
return
576+
}
577+
578+
const seedPaths = displayRuntimeSeedPaths(targetDir, spec, '.dll')
579+
const neededLibraries = collectWindowsLibraryDependencies(targetDir, seedPaths)
580+
for (const entry of readdirSync(binDir)) {
581+
if (entry.toLowerCase().endsWith('.dll') && !neededLibraries.has(entry)) {
582+
rmSync(resolve(binDir, entry), { force: true })
583+
}
584+
}
585+
}
586+
415587
function pruneRuntimeLayout(targetDir, spec) {
416588
if (process.env.GSTREAMER_KEEP_FULL_LAYOUT) {
417589
return
@@ -479,6 +651,7 @@ function stripDarwinBinaries(targetDir) {
479651
function pruneDisplayRuntime(targetDir, spec) {
480652
pruneDisplayPlugins(targetDir)
481653
pruneDarwinLibraries(targetDir, spec)
654+
pruneWindowsLibraries(targetDir, spec)
482655
pruneRuntimeLayout(targetDir, spec)
483656
stripDarwinBinaries(targetDir)
484657
}

0 commit comments

Comments
 (0)