Merge Luma launcher fork into monorepo
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s

Move trampoline files (boot-hook, watcher, overlay) into trampoline/
subdirectory. Add upstream Luma v2.3.1 source at repo root with our
additions:

- Page::Ha (4th nav tab) with placeholder empty state
- HA tab config schema and default entity mapping (ha-home/)
- Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings
- All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 11:52:55 +03:00
1 parent bc3ca0f3b9
commit cbea5a322f
204 files changed
+25056 -65

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import sharp from 'sharp';
import { generateVectorAssets } from './vector-assets.mjs';
const root = path.resolve(import.meta.dirname, '..');
const source = path.join(root, 'assets');
const out = path.join(root, '.build/assets');
const highResolution = process.argv.includes('--4k');
// Retire generated dumps from older asset caches as well as the source assets.
for (const legacy of ['app-icons', 'icons.json', 'colors.json']) {
await fs.rm(path.join(out, legacy), { recursive: true, force: true });
}
for (const directory of ['wallpapers', 'thumbnails', 'logos', 'ui-icons', 'surfaces', 'splash']) {
await fs.mkdir(path.join(out, directory), { recursive: true });
}
// Decode the existing licensed font, rather than introducing a different font.
const font = path.join(out, 'font.woff2');
await fs.copyFile(path.join(source, 'fonts/inter-latin.woff2'), font);
const decoded = spawnSync('woff2_decompress', [font], { encoding: 'utf8' });
if (decoded.error || decoded.status !== 0) {
throw new Error('Font conversion needs woff2_decompress. Run pnpm assets inside nix develop.', {
cause: decoded.error ?? new Error(decoded.stderr),
});
}
await fs.rm(font);
const cjk = path.join(source, 'fonts/noto-sans-sc.ttf');
const hangul = path.join(source, 'fonts/noto-sans-kr.ttf');
try {
await fs.access(cjk);
await fs.access(hangul);
} catch {
throw new Error('Prepare the bundled Chinese and Korean fonts with pnpm assets:fonts first.');
}
const weights = spawnSync(
'python3',
[path.join(import.meta.dirname, 'font-weights.py'), out, cjk, hangul],
{
encoding: 'utf8',
},
);
if (weights.error || weights.status !== 0) {
throw new Error('Font weights need Python/fontTools. Run pnpm assets inside nix develop.', {
cause: weights.error ?? new Error(weights.stderr),
});
}
await fs.copyFile(path.join(source, 'fonts/ofl.txt'), path.join(out, 'font-license.txt'));
await fs.copyFile(path.join(source, 'fonts/noto-ofl.txt'), path.join(out, 'font-cjk-license.txt'));
for (const file of await fs.readdir(path.join(source, 'wallpapers'))) {
if (!/\.(jpg|svg)$/.test(file)) continue;
const id = path.parse(file).name;
const input = path.join(source, 'wallpapers', file);
for (const scale of highResolution ? [1, 2] : [1]) {
const width = 1920 * scale;
const height = 1080 * scale;
// Match background-size:cover and each CSS background-position exactly.
const photo = await sharp(input, { density: 72 * scale })
.resize(width, height, { fit: 'outside' })
.png()
.toBuffer({ resolveWithObject: true });
const position = id === 'dunes' || id === 'summit' ? 0.4 : id === 'san-francisco' ? 0.42 : 0.45;
let image = sharp(photo.data).extract({
left: Math.round((photo.info.width - width) / 2),
top: Math.round((photo.info.height - height) * position),
width,
height,
});
const saturation =
id === 'san-francisco'
? 0.75
: id === 'summit'
? 0.7
: ['forest', 'yosemite', 'northern-lights'].includes(id)
? 0.8
: 1;
const brightness = id === 'san-francisco' ? 0.82 : id === 'summit' ? 0.8 : 1;
if (saturation !== 1) {
const s = saturation;
image = image.recomb([
[0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s],
[0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s],
[0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s],
]);
}
const data = await image.linear(brightness).png().toBuffer();
const shaded = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 1920 1080"><defs>
<linearGradient id="v" x2="0" y2="1"><stop stop-color="#0d1013" stop-opacity=".24"/><stop offset=".19" stop-color="#0e1115" stop-opacity=".08"/><stop offset=".35" stop-color="#0f1114" stop-opacity=".35"/><stop offset=".63" stop-color="#111317" stop-opacity=".86"/><stop offset=".85" stop-color="#17191d" stop-opacity=".92157"/><stop offset="1" stop-color="#17191d" stop-opacity=".96078"/></linearGradient>
<linearGradient id="h"><stop stop-color="#111417" stop-opacity=".42"/><stop offset=".75" stop-color="#111417" stop-opacity="0"/></linearGradient></defs>
<path fill="#17191c" d="M0 0h1920v1080H0z"/><image href="data:image/png;base64,${data.toString('base64')}" width="1920" height="1080" opacity=".92"/><path fill="url(#h)" d="M0 0h1920v1080H0z"/><path fill="url(#v)" d="M0 0h1920v1080H0z"/></svg>`;
// This generated SVG embeds a trusted bundled photograph; its data URL can
// exceed libxml's default 10 MiB attribute limit at 4K.
await sharp(Buffer.from(shaded), { unlimited: true })
.jpeg({ quality: 96 })
.toFile(path.join(out, 'wallpapers', `${id}${scale === 2 ? '@2x' : ''}.jpg`));
}
await sharp(input)
.resize(480, 270, { fit: 'cover' })
.jpeg({ quality: 82 })
.toFile(path.join(out, 'thumbnails', `${id}.jpg`));
}
// webOS shows `splashBackground` (a static image, scaled to 1080p) until the window opens. One
// 1080p PNG per background lets the package start on the user's wallpaper instead of a grey card.
for (const file of await fs.readdir(path.join(out, 'wallpapers'))) {
if (!file.endsWith('.jpg') || file.includes('@2x')) continue;
await sharp(path.join(out, 'wallpapers', file))
.resize(1920, 1080, { fit: 'cover' })
.png({ compressionLevel: 9 })
.toFile(path.join(out, 'splash', file.replace(/\.jpg$/, '.png')));
}
await fs.writeFile(
path.join(out, 'version.txt'),
JSON.parse(await fs.readFile(path.join(root, 'appinfo.json'), 'utf8')).version,
);
const surfaces = {
generic: ['#4e5c6b', '#243c4e'],
'live-tv': ['#192a43', '#0d1628'],
netflix: ['#141414', '#141414'],
youtube: ['#0f0f0f', '#0f0f0f'],
'apple-tv': ['#252525', '#080808'],
'disney-plus': ['#0b244c', '#091936'],
'prime-video': ['#0786bd', '#005ba7'],
arte: ['#242323', '#121212'],
zdf: ['#121b22', '#121b22'],
'das-erste': ['#064c84', '#00254e'],
plex: ['#343434', '#232323'],
spotify: ['#000000', '#121212'],
jellyfin: ['#25283f', '#101422'],
homebrew: ['#7c6042', '#3f392d'],
kodi: ['#0a749b', '#08445e'],
moonlight: ['#435d8f', '#252e59'],
gaming: ['#435d8f', '#252e59'],
browser: ['#192a43', '#0d1628'],
'hbo-max': ['#181818', '#080808'],
'paramount-plus': ['#075bda', '#00298e'],
peacock: ['#171717', '#080808'],
hulu: ['#152d23', '#091710'],
crunchyroll: ['#141414', '#141414'],
twitch: ['#6330b8', '#32165c'],
dazn: ['#252623', '#10110e'],
'rakuten-tv': ['#bf143a', '#620c20'],
'apple-music': ['#e9415e', '#a91c38'],
'amazon-music': ['#183c4a', '#102128'],
'bbc-iplayer': ['#4a162e', '#230b16'],
itvx: ['#24341b', '#111a0c'],
'channel-4': ['#20362c', '#0c1a14'],
'now-tv': ['#064c45', '#001e1a'],
'rtl-plus': ['#262029', '#121014'],
joyn: ['#000000', '#000000'],
'waipu-tv': ['#154e8e', '#0a2544'],
zattoo: ['#353031', '#151314'],
skyshowtime: ['#24202e', '#100d17'],
'coupang-play': ['#143b4a', '#091d25'],
tving: ['#3f131d', '#1a090e'],
wavve: ['#10399e', '#0a1e53'],
watcha: ['#3d1225', '#1e0912'],
'movistar-plus': ['#163b35', '#091e1a'],
filmin: ['#10372f', '#091c18'],
'nrk-tv': ['#073b84', '#04214b'],
'tv2-play': ['#1d1530', '#0b0812'],
};
for (const [brand, [top, bottom]] of Object.entries(surfaces)) {
const fill =
brand === 'disney-plus'
? '<radialGradient id="base" cx="50%" cy="110%" r="110%"><stop stop-color="#16466b"/><stop offset=".6" stop-color="#0b244c"/><stop offset="1" stop-color="#091936"/></radialGradient>'
: `<linearGradient id="base" x2="1" y2="1"><stop stop-color="${top}"/><stop offset="1" stop-color="${bottom}"/></linearGradient>`;
await sharp(
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="540" height="309"><defs>${fill}<linearGradient id="sheen" x2="1" y2="1"><stop stop-color="white" stop-opacity=".04"/><stop offset=".48" stop-color="white" stop-opacity="0"/></linearGradient></defs><path fill="url(#base)" d="M0 0h540v309H0z"/><path fill="url(#sheen)" d="M0 0h540v309H0z"/></svg>`,
),
)
.png()
.toFile(path.join(out, 'surfaces', `${brand}.png`));
}
await generateVectorAssets();
await sharp(path.join(source, 'app-icon.svg'))
.resize(80, 80)
.png()
.toFile(path.join(out, 'app-icon.png'));
await sharp(path.join(source, 'app-icon.svg'))
.resize(130, 130)
.png()
.toFile(path.join(out, 'app-icon-large.png'));
console.log(`Native artwork ready: ${path.relative(root, out)}`);
+224
View File
@@ -0,0 +1,224 @@
// Optional host-only source refresh. Normal builds use the checked-in SVGs and
// never contact these hosts. Hashes cover the original bytes before adaptation.
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
const root = path.resolve(import.meta.dirname, '..');
const simple =
'https://raw.githubusercontent.com/simple-icons/simple-icons/90bd1f3e7b77851fd0c56474f6fa43b816babdab';
const commons = (file) => {
file = decodeURIComponent(file);
const hash = createHash('md5').update(file).digest('hex');
return `https://upload.wikimedia.org/wikipedia/commons/${hash[0]}/${hash.slice(0, 2)}/${encodeURIComponent(file)}`;
};
export const sources = [
[
'spotify',
commons('Spotify_logo_with_text.svg'),
'c0e57c534e7fce5e66fb419c269b97d436385a2c69b9f508edf480ef60dedf91',
],
[
'plex',
`${simple}/icons/plex.svg`,
'129500c2bda2175c95402d12e0a96642de61a2561d45a4d9719edc8abd43c8ea',
],
[
'hbo-max',
commons('HBO_Max_%282025%29.svg'),
'c8728242f74e388ef3a507745875335528a4980123f2c4cae9f08d666fbda63b',
],
[
'paramount-plus',
`${simple}/icons/paramountplus.svg`,
'6b8a777222266eefdb08b9737cef97cdecf10143f5402d06634f615b127851f9',
],
[
'peacock',
commons('NBCUniversal_Peacock_Logo_%282026%29.svg'),
'bf53d69c5deda4de4db1997d0616cfc43ede490f5634003bfcc5094477595f2c',
],
[
'hulu',
commons('Hulu_logo_%282018%29.svg'),
'6bf705502216ffd2ad58721bcb1b76b93d3a2a31995dfa919ef34a847d038e1f',
],
[
'crunchyroll',
`${simple}/icons/crunchyroll.svg`,
'3b9c3d87339e18ec09f25e0c3eaffdc5ad4630df3d108fcd907e1b64c4cd13ea',
],
[
'twitch',
`${simple}/icons/twitch.svg`,
'dbe27bc02a01b8d89b6259cacef328c9fa70bb13c29f38c36eb1421863f6f0b6',
],
[
'dazn',
`${simple}/icons/dazn.svg`,
'19d9da58d96ee8bd05bf7253f706203dd0c1762469523b8960eb8efc3b1017ef',
],
[
'rakuten-tv',
commons('Rakuten_TV_logo.svg'),
'43b83db4948b04a3072ce264265452e59a65a4ccc4e32c710c73044ebf49ae86',
],
[
'apple-music',
commons('Apple_Music_Logo_%282026%29.svg'),
'974eab4dbecce108dcfc5299bce64a5461eb57a56a259d59d9e52bc4d9b29556',
],
[
'amazon-music',
commons('Amazon_Music_logo.svg'),
'71b407417a0a20e42c79e6e0a643c76fbd56e4aaacc0b7335178e13fd458ef60',
],
[
'bbc-iplayer',
commons('BBC_iPlayer_2021_%28Alt%29.svg'),
'05325387e1af09f7f177eb4dbb20225793949f92f6295bfd8604a01c2c98f63e',
],
[
'itvx',
`${simple}/icons/itvx.svg`,
'eafc08ffdda9c16ec292c03ed795a1111945240670f50ef6515b896efc8b4153',
],
[
'channel-4',
`${simple}/icons/channel4.svg`,
'cb7205ad42186cf48bb30e350753d0323e1f4efd03a33749c7e17e7868d4978a',
],
[
'now-tv',
`${simple}/icons/now.svg`,
'bcffe72bc6cc06123c6e2b736fb7c2d2ff432025ebd014118702b5b670d17a85',
],
[
'rtl-plus',
commons('RTL%2B_Logo_2021.svg'),
'4f28b89be7c76acec5b467e66df51fb332ea0c46a9cde4f3be6fb5270c105884',
],
[
'joyn',
commons('Joyn_logo.svg'),
'a5b387fbb02c2be6c1408e0d56d4c4ee32fd19eaf86a776fa05de38d6afb0570',
],
[
'waipu-tv',
commons('Waipu_TV_Logo_2016.svg'),
'1013fbb5274899a386f57b34413165b9a66933a1614855c5acaa2553a942be97',
],
[
'zattoo',
commons('Logo_Zattoo.svg'),
'2df3c7eaf5e953f2aef43353c622f6c6c06688a9649995b88effb2d1fc0fce5d',
],
[
'skyshowtime',
commons('SkyShowtime_Logo.svg'),
'da31ab32038e050cb759335057243054034556117552fa88d4e14457503daa92',
],
[
'coupang-play',
'https://raw.githubusercontent.com/SinJungho/Damoa_Team-Project/db1955f0f6f09d9329518120afe42af375632de0/src/svg/CoupangPlayLogo.jsx',
'0069daf83ee2b3389d6082d8dd09ed2007872752beab3191ba7922935ac99c36',
],
[
'tving',
commons('TVING_logo.svg'),
'4dd2952a96b1b0b4d879d1b6444e137806cddee3cd8352145d7266f4f726964c',
],
[
'wavve',
'https://www.wavve.com/static/element-icons-tag-ic-brand-wavve-b343dcb2.svg',
'b343dcb207257023fe27845d230699d6320134b780e8571f1e71095f8c71f0a8',
],
[
'watcha',
commons('Watcha_logo.svg'),
'6d1239c5fd4d9ad9e8dd54d07e13c65bf970552195ee6243ace814d3c5aff18a',
],
[
'movistar-plus',
commons('Movistar_Plus%2B_2022_logo.svg'),
'f8b775c43d273a9e1d97b539c22356392146271c89a9f2a9fb851863ea0600a8',
],
[
'filmin',
'https://raw.githubusercontent.com/DCueto/filmin-svelte-clone/36c977e31ec76abb42473b243584acbaed23579f/src/assets/filmin_logo.svg',
'f9fbaec75b29bedec27236bf3717701b8871344e5bcb0ac8bc2e8c9a0c127b4d',
],
];
async function download(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(60000) });
if (!response.ok) throw new Error(`${response.status}: ${url}`);
const text = await response.text();
if (!text.includes('<svg') || text.includes('data:image/'))
throw new Error(`Not standalone vector artwork: ${url}`);
return text;
}
// The Coupang SVG was distributed as a component. Keep only its static SVG
// paths, resolving numeric JSX attributes and removing its redundant bounds clip.
export function prepareSvg(name, original, url) {
let svg = original.replace(/<\?xml[^?]*\?>/, '');
if (name === 'crunchyroll') {
svg = svg.replace('<svg ', '<svg fill="#FF5E00" ');
}
if (name === 'coupang-play') {
svg = svg
.slice(svg.indexOf('<svg'), svg.lastIndexOf('</svg>') + 6)
.replace(/style=\{\{[\s\S]*?\}\}/g, '')
.replace(/=\{([\d.]+)\}/g, '="$1"')
.replace(/<defs>[\s\S]*?<\/defs>/g, '')
.replace(/\sclip-path="[^"]*"/g, '');
}
if (['peacock', 'watcha'].includes(name)) {
// These clips only repeat the artwork's rectangular outer bounds. Keep
// gradient definitions while removing the redundant clipping wrapper.
svg = svg
.replace(/<clipPath\b[^>]*>[\s\S]*?<\/clipPath>/g, '')
.replace(/\sclip-path="[^"]*"/g, '');
}
// Source provenance stays beside the original paths. Display-only white
// treatments and tight bounds are applied by the host mesh compiler.
return `<!-- Source: ${url} -->\n${svg.trim()}\n`;
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
const checksums = process.argv.includes('--checksums');
const only = process.argv.includes('--only')
? process.argv[process.argv.indexOf('--only') + 1]
: undefined;
if (process.argv.includes('--only') && !sources.some(([name]) => name === only)) {
throw new Error('Pass a known brand name after --only');
}
for (const [name, url, expected] of sources) {
if (only && name !== only) continue;
let original;
try {
original = await download(url);
} catch (error) {
if (!checksums) throw error;
console.error(`${name}: ${error.message}`);
continue;
}
const digest = createHash('sha256').update(original).digest('hex');
if (checksums) {
console.log(`${name} ${digest} ${Buffer.byteLength(original)}`);
continue;
}
if (digest !== expected) throw new Error(`Source checksum mismatch: ${name}`);
await fs.writeFile(
path.join(root, 'assets/logos', `${name}.svg`),
prepareSvg(name, original, url),
);
console.log(`Restored vector source: ${name}`);
}
if (!checksums && !only) {
const response = await fetch(`${simple}/LICENSE.md`, { signal: AbortSignal.timeout(60000) });
if (!response.ok) throw new Error('Could not restore the Simple Icons CC0 notice');
await fs.writeFile(path.join(root, 'assets/licenses/simple-icons.txt'), await response.text());
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
set -eu
# Use the webOS NDK's ARM soft-float ABI and sysroot for Rust AND SDL linking.
: "${WEBOS_NDK:?Set WEBOS_NDK to the extracted and relocated webOS native toolchain}"
ROOT=$(dirname "$(dirname "$(realpath "$0")")")
WEBOS_NDK=$(realpath "$WEBOS_NDK")
CC="$WEBOS_NDK/bin/arm-webos-linux-gnueabi-gcc"
[ -x "$CC" ] || { printf '%s\n' "Missing webOS compiler: $CC" >&2; exit 1; }
SYSROOT=$("$CC" -print-sysroot)
# Some relocated macOS SDKs report a nonexistent compiler-relative sysroot.
if [ ! -f "$SYSROOT/usr/include/stdint.h" ]; then
SYSROOT="$WEBOS_NDK/arm-webos-linux-gnueabi/sysroot"
fi
[ -f "$SYSROOT/usr/include/stdint.h" ] || { printf '%s\n' "Missing webOS sysroot headers: $SYSROOT" >&2; exit 1; }
node "$ROOT/tools/webos-runtime.mjs"
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_LINKER="$CC"
# The target's soft-float *calling convention* is required by webOS, but its
# default software FP codegen is not. Match the NDK's ARMv7/VFP baseline while
# retaining the gnueabi ABI; keep NEON off for portable Rust scalar code.
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS="${CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS:-} -C target-cpu=cortex-a9 -C target-feature=-neon,-soft-float -C link-arg=--sysroot=$SYSROOT -C link-arg=-Wl,-rpath,\$ORIGIN/lib"
export CC_armv7_unknown_linux_gnueabi="$CC"
export AR_armv7_unknown_linux_gnueabi="$WEBOS_NDK/bin/arm-webos-linux-gnueabi-ar"
export CFLAGS_armv7_unknown_linux_gnueabi="${CFLAGS_armv7_unknown_linux_gnueabi:-} --sysroot=$SYSROOT"
export PKG_CONFIG_ALLOW_CROSS=1
# Nix's desktop SDL pkg-config path must not leak into this ARM build.
export PKG_CONFIG_PATH=
# This metadata contains absolute paths to the verified cross-built SDL runtime,
# so pkg-config must not prepend the target sysroot to those build-host paths.
export PKG_CONFIG_SYSROOT_DIR=/
export PKG_CONFIG_LIBDIR="$ROOT/.build/sdl-webos/pkgconfig"
exec cargo build --locked --manifest-path "$ROOT/Cargo.toml" --package luma --target armv7-unknown-linux-gnueabi --features webos --release
+26
View File
@@ -0,0 +1,26 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = path.resolve(import.meta.dirname, '..');
const deviceIndex = process.argv.indexOf('--device');
const device = deviceIndex >= 0 ? process.argv[deviceIndex + 1] : 'tv';
if (!device || !/^[a-zA-Z0-9_-]+$/.test(device))
throw new Error('Pass an ares device profile: pnpm run deploy --device tv');
const manifest = JSON.parse(await fs.readFile(path.join(root, 'appinfo.json'), 'utf8'));
for (const [tool, args] of [
[
'ares-install',
['--device', device, path.join(root, 'dist', `${manifest.id}_${manifest.version}_arm.ipk`)],
],
['ares-launch', ['--device', device, manifest.id]],
]) {
const cli = path.join(root, 'node_modules/@webos-tools/cli/bin', `${tool}.js`);
const result = spawnSync(process.execPath, [cli, ...args], {
cwd: root,
stdio: 'inherit',
timeout: 120000,
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
+48
View File
@@ -0,0 +1,48 @@
// One-time native font preparation; normal asset builds and TV use are offline.
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
const directory = path.resolve(import.meta.dirname, '../assets/fonts');
const base =
'https://raw.githubusercontent.com/google/fonts/a85815a42757630ce188fdad368c2dfc444d4773/ofl';
// Google Fonts ships each CJK language as its own family: Noto Sans SC has no
// Hangul syllables, so Korean needs Noto Sans KR. Both share this OFL text.
const files = [
[
'noto-sans-sc.ttf',
'notosanssc/NotoSansSC%5Bwght%5D.ttf',
'a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da',
],
[
'noto-sans-kr.ttf',
'notosanskr/NotoSansKR%5Bwght%5D.ttf',
'194018e6b2b293a7964f037b25c0249ce1418bc9ab3c971060a03aa57861e252',
],
[
'noto-ofl.txt',
'notosanssc/OFL.txt',
'1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9',
],
];
await fs.mkdir(directory, { recursive: true });
for (const [name, source, hash] of files) {
const destination = path.join(directory, name);
let bytes;
try {
bytes = await fs.readFile(destination);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (!bytes) {
const response = await fetch(`${base}/${source}`, { signal: AbortSignal.timeout(60000) });
if (!response.ok) throw new Error(`Font download failed: ${response.status}`);
bytes = Buffer.from(await response.arrayBuffer());
if (createHash('sha256').update(bytes).digest('hex') !== hash)
throw new Error(`Font checksum mismatch: ${name}`);
await fs.writeFile(destination, bytes);
} else if (createHash('sha256').update(bytes).digest('hex') !== hash) {
throw new Error(`Bundled font differs from the pinned source: ${name}`);
}
console.log(`Bundled native font: ${name}`);
}
+64
View File
@@ -0,0 +1,64 @@
"""Freeze the bundled variable Inter font at the web UI's weights/optical sizes."""
import sys
import json
from pathlib import Path
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont
from fontTools import subset
directory = Path(sys.argv[1])
font = TTFont(directory / "font.ttf")
axes = {axis.axisTag for axis in font["fvar"].axes} if "fvar" in font else set()
metrics = {}
for weight in (350, 400, 450, 500, 550, 600, 650, 800):
for display in (False, True):
values = {"wght": weight} if "wght" in axes else {}
if "opsz" in axes:
values["opsz"] = 32 if display else 14
instance = instantiateVariableFont(font, values, inplace=False) if axes else font
instance.save(directory / f"font-{'display-' if display else ''}{weight}.ttf")
units = instance["head"].unitsPerEm
advances = instance["hmtx"].metrics
metrics[f"{'display-' if display else ''}{weight}"] = {
chr(codepoint): advances[glyph][0] / units
for codepoint, glyph in instance.getBestCmap().items()
}
def is_hangul(codepoint):
# Keep in sync with is_hangul() in src/renderer.rs, which routes these to font-kr.
return (0x1100 <= codepoint <= 0x11FF or 0x3130 <= codepoint <= 0x318F
or 0xA960 <= codepoint <= 0xA97F or 0xAC00 <= codepoint <= 0xD7A3
or 0xD7B0 <= codepoint <= 0xD7FF)
def fallback(path, keep, family, prefix):
font = TTFont(path)
options = subset.Options()
options.layout_features = []
options.name_IDs = ["*"]
subsetter = subset.Subsetter(options=options)
subsetter.populate(unicodes=[codepoint for codepoint in font.getBestCmap() if keep(codepoint)])
subsetter.subset(font)
advances_by_weight = {}
for weight in (400, 500):
instance = instantiateVariableFont(font, {"wght": weight}, inplace=False)
# Derived subsets use their own family name while retaining copyright/license tables.
style = "Regular" if weight == 400 else "Medium"
for name_id, value in [(1, family), (2, style), (4, f"{family} {style}"),
(6, f"{family.replace(' ', '')}-{style}"), (16, family), (17, style)]:
instance["name"].setName(value, name_id, 3, 1, 0x409)
instance.save(directory / f"{prefix}-{weight}.ttf")
units = instance["head"].unitsPerEm
advances = instance["hmtx"].metrics
advances_by_weight[weight] = {chr(cp): advances[glyph][0] / units
for cp, glyph in instance.getBestCmap().items()}
return advances_by_weight
cjk = fallback(sys.argv[2],
lambda cp: cp < 0xA000 or 0xF900 <= cp <= 0xFAFF or 0xFF00 <= cp <= 0xFFEF,
"Luma CJK", "font-cjk")
# Noto Sans SC carries no Hangul syllables; Korean comes from Noto Sans KR.
hangul = fallback(sys.argv[3], is_hangul, "Luma KR", "font-kr")
for weight in (400, 500):
metrics[f"fallback-{weight}"] = {**cjk[weight], **hangul[weight]}
(directory / "font-metrics.json").write_text(json.dumps(metrics, ensure_ascii=False), encoding="utf8")
+187
View File
@@ -0,0 +1,187 @@
// Inspect the actual IPK, and normalize only Luma-owned entries. The webOS CLI
// emits world-writable directories; boot scripts need app-owned 0755 parents.
import { gunzipSync, gzipSync } from 'node:zlib';
function members(bytes) {
if (bytes.subarray(0, 8).toString() !== '!<arch>\n') throw new Error('Not an IPK/ar archive.');
const result = [];
for (let offset = 8; offset + 60 <= bytes.length;) {
const header = Buffer.from(bytes.subarray(offset, offset + 60));
const size = Number(header.subarray(48, 58).toString().trim());
if (!Number.isSafeInteger(size) || size < 0 || offset + 60 + size > bytes.length)
throw new Error('Invalid IPK archive member.');
result.push({
header,
name: header.subarray(0, 16).toString().trim().replace(/\/$/, ''),
data: bytes.subarray(offset + 60, offset + 60 + size),
});
offset += 60 + size + (size % 2);
}
return result;
}
function entries(tar) {
const result = [];
for (let offset = 0; offset + 512 <= tar.length;) {
const header = tar.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const text = (start, length) =>
header
.subarray(start, start + length)
.toString()
.replace(/\0.*$/, '');
const size = parseInt(text(124, 12).trim(), 8) || 0;
if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length)
throw new Error('Invalid package tar member.');
const prefix = text(345, 155);
result.push({
header,
name: (prefix ? `${prefix}/` : '') + text(0, 100),
type: text(156, 1),
data: tar.subarray(offset + 512, offset + 512 + size),
});
offset += 512 + Math.ceil(size / 512) * 512;
}
return result;
}
export function normalizeAndVerifyPackage(bytes, manifest) {
const archive = members(bytes);
const member = archive.find((entry) => entry.name === 'data.tar.gz');
if (!member || !archive.some((entry) => entry.name === 'control.tar.gz'))
throw new Error('Incomplete IPK.');
const tar = gunzipSync(member.data);
const files = entries(tar);
const owned = files.filter((file) => file.name.split('/').includes(manifest.id));
if (!owned.length) throw new Error('The IPK contains no Luma app directory.');
if (
owned.some((file) => /\/assets\/(app-icons(?:\/|$)|icons\.json$|colors\.json$)/.test(file.name))
)
throw new Error('Retired dumped artwork must not be shipped in the TV package.');
const appinfo = owned.find((file) => file.name.endsWith('/appinfo.json'));
const actual = appinfo && JSON.parse(appinfo.data.toString());
if (
actual?.id !== manifest.id ||
actual?.version !== manifest.version ||
actual.type !== 'native' ||
actual.main !== manifest.main
)
throw new Error('Packaged app metadata does not match the source.');
if (JSON.stringify(actual.requiredACG) !== JSON.stringify(manifest.requiredACG))
throw new Error('Packaged app permissions do not match the source.');
if (actual.icon !== manifest.icon || actual.largeIcon !== manifest.largeIcon)
throw new Error('Packaged icon paths do not match the source.');
const vectorManifest = owned.find((file) => file.name.endsWith('/assets/vector-artwork.json'));
const vectorAssets = vectorManifest && JSON.parse(vectorManifest.data.toString());
if (
vectorAssets?.version !== 1 ||
!Array.isArray(vectorAssets.assets) ||
!vectorAssets.assets.length
)
throw new Error('The IPK is missing its vector artwork manifest.');
for (const required of [
manifest.main,
manifest.icon,
manifest.largeIcon,
'autostart.sh',
'home.sh',
'home.cjs',
'splash.png',
'lib/libSDL2-2.0.so.0',
'licenses/sdl2.txt',
'licenses/lucide.txt',
'assets/font-metrics.json',
'assets/font-license.txt',
'assets/font-cjk-license.txt',
'assets/version.txt',
...[350, 400, 450, 500, 550, 600, 650, 800].flatMap((weight) => [
`assets/font-${weight}.ttf`,
`assets/font-display-${weight}.ttf`,
]),
'assets/font-cjk-400.ttf',
'assets/font-cjk-500.ttf',
'assets/font-kr-400.ttf',
'assets/font-kr-500.ttf',
...[
'dunes',
'midnight',
'san-francisco',
'summit',
'slate',
'forest',
'prism',
'yosemite',
'northern-lights',
].flatMap((name) => [
`assets/wallpapers/${name}.jpg`,
`assets/thumbnails/${name}.jpg`,
`splash/${name}.png`,
]),
...vectorAssets.assets.map((name) => {
if (typeof name !== 'string' || !/^(logos|ui-icons)\/[a-z0-9-]+\.lvec$/.test(name))
throw new Error('Invalid packaged vector asset path.');
return `assets/${name}`;
}),
]) {
if (!owned.some((file) => file.name.endsWith(`/${required}`) && file.data.length > 0))
throw new Error(`Package is missing ${required}.`);
}
const executable = owned.find((file) => file.name.endsWith(`/${manifest.main}`))?.data;
if (
!executable ||
executable.length < 52 ||
executable.subarray(0, 4).toString('hex') !== '7f454c46' ||
executable[4] !== 1 ||
executable[5] !== 1 ||
executable.readUInt16LE(18) !== 40 ||
(executable.readUInt32LE(36) & 0x400) !== 0
)
throw new Error('Packaged executable must be an ARM soft-float ELF.');
const controlArchive = archive.find((entry) => entry.name === 'control.tar.gz');
const control = entries(gunzipSync(controlArchive.data))
.find((file) => file.name.replace(/^\.\//, '') === 'control')
?.data.toString();
if (!control || !/^Architecture: arm\s*$/m.test(control))
throw new Error('The IPK must declare Architecture: arm.');
const version = owned
.find((file) => file.name.endsWith('/assets/version.txt'))
?.data.toString()
.trim();
if (version !== manifest.version)
throw new Error('Packaged assets were generated for another version.');
const vectorFiles = owned.filter((file) => file.name.endsWith('.lvec'));
for (const file of vectorFiles) {
if (
file.data.length < 20 ||
file.data.subarray(0, 8).toString() !== 'LUMAVEC1' ||
file.data.readUInt32LE(16) === 0 ||
file.data.readUInt32LE(16) % 3 !== 0 ||
file.data.length !== 20 + file.data.readUInt32LE(16) * 36
)
throw new Error(`Invalid packaged vector geometry: ${file.name}`);
}
for (const file of owned) {
if (!['', '0', '5'].includes(file.type))
throw new Error(`Unexpected package entry type: ${file.name}`);
const executable =
file.type === '5' ||
file.name.endsWith('/autostart.sh') ||
file.name.endsWith('/home.sh') ||
file.name.endsWith(`/${manifest.main}`);
file.header.write(executable ? '0000755\0' : '0000644\0', 100, 8, 'ascii');
file.header.fill(32, 148, 156);
const sum = file.header.reduce((total, byte) => total + byte, 0);
file.header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii');
}
member.data = gzipSync(tar, { level: 9 });
const parts = [Buffer.from('!<arch>\n')];
for (const entry of archive) {
entry.header.write(String(entry.data.length).padEnd(10, ' '), 48, 10, 'ascii');
parts.push(entry.header, entry.data);
if (entry.data.length % 2) parts.push(Buffer.from('\n'));
}
console.log(
`Verified IPK contents: ${owned.length} Luma entries; app metadata, assets and modes checked.`,
);
return Buffer.concat(parts);
}
+156
View File
@@ -0,0 +1,156 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { prepareWebosRuntime, verifyWebosSdl } from './webos-runtime.mjs';
import { normalizeAndVerifyPackage } from './ipk.mjs';
const root = path.resolve(import.meta.dirname, '..');
const highResolution = process.argv.includes('--4k');
const manifest = JSON.parse(await fs.readFile(path.join(root, 'appinfo.json'), 'utf8'));
const project = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
if (
project.version !== manifest.version ||
manifest.id !== 'com.ecklf.luma' ||
manifest.type !== 'native' ||
manifest.main !== 'luma'
) {
throw new Error(
'Native package metadata must use com.ecklf.luma, main luma, and synchronized versions.',
);
}
const binary = path.join(root, 'target/armv7-unknown-linux-gnueabi/release/luma');
const bytes = await fs.readFile(binary);
if (
bytes.length < 52 ||
bytes.subarray(0, 4).toString('hex') !== '7f454c46' ||
bytes[4] !== 1 ||
bytes[5] !== 1 ||
bytes.readUInt16LE(18) !== 40 ||
(bytes.readUInt32LE(36) & 0x400) !== 0
) {
throw new Error(
'Packaging requires a 32-bit little-endian ARM soft-float ELF from build-webos.sh.',
);
}
const out = path.join(root, '.build');
const dist = path.join(root, 'dist');
let vectorManifest;
try {
vectorManifest = JSON.parse(
await fs.readFile(path.join(out, 'assets/vector-artwork.json'), 'utf8'),
);
} catch (error) {
throw new Error('Prepare vector artwork with pnpm assets:vector before packaging.', {
cause: error,
});
}
if (
vectorManifest.version !== 1 ||
!Array.isArray(vectorManifest.assets) ||
vectorManifest.assets.length === 0
) {
throw new Error('Invalid native vector artwork manifest. Regenerate native assets.');
}
for (const file of vectorManifest.assets) {
if (typeof file !== 'string' || !/^(logos|ui-icons)\/[a-z0-9-]+\.lvec$/.test(file)) {
throw new Error('Invalid native vector artwork path.');
}
const mesh = await fs.readFile(path.join(out, 'assets', file));
if (
mesh.length < 20 ||
mesh.subarray(0, 8).toString() !== 'LUMAVEC1' ||
mesh.readUInt32LE(16) === 0 ||
mesh.readUInt32LE(16) % 3 !== 0 ||
mesh.length !== 20 + mesh.readUInt32LE(16) * 36
) {
throw new Error(`Invalid native vector geometry: ${file}`);
}
}
const stage = path.join(out, 'package');
await fs.rm(stage, { recursive: true, force: true });
await fs.mkdir(stage, { recursive: true });
await fs.mkdir(dist, { recursive: true });
await fs.copyFile(binary, path.join(stage, manifest.main));
await fs.chmod(path.join(stage, manifest.main), 0o755);
await fs.cp(path.join(out, 'assets'), path.join(stage, 'assets'), {
recursive: true,
filter: (source) =>
path.basename(source) !== '.DS_Store' &&
// Protect direct packaging calls that reuse an older generated asset cache.
!['app-icons', 'icons.json', 'colors.json', 'splash'].includes(path.basename(source)) &&
(highResolution || !source.endsWith('@2x.jpg')) &&
!(
source.endsWith('.png') && ['logos', 'ui-icons'].includes(path.basename(path.dirname(source)))
),
});
await fs.copyFile(path.join(out, 'assets/app-icon.png'), path.join(stage, 'app-icon.png'));
await fs.copyFile(
path.join(out, 'assets/app-icon-large.png'),
path.join(stage, 'app-icon-large.png'),
);
await fs.copyFile(path.join(root, 'tv/autostart.sh'), path.join(stage, 'autostart.sh'));
// Splash images: one per background, plus splash.png (the default background) read by webOS.
await fs.mkdir(path.join(stage, 'splash'), { recursive: true });
for (const file of await fs.readdir(path.join(out, 'assets/splash')))
await fs.copyFile(path.join(out, 'assets/splash', file), path.join(stage, 'splash', file));
await fs.copyFile(path.join(out, 'assets/splash/dunes.png'), path.join(stage, 'splash.png'));
await fs.chmod(path.join(stage, 'autostart.sh'), 0o755);
for (const file of ['home.sh', 'home.cjs']) {
await fs.copyFile(path.join(root, 'tv', file), path.join(stage, file));
await fs.chmod(path.join(stage, file), file.endsWith('.sh') ? 0o755 : 0o644);
}
await fs.copyFile(path.join(root, 'license'), path.join(stage, 'LICENSE'));
await fs.copyFile(path.join(root, 'assets.md'), path.join(stage, 'ASSETS.md'));
await fs.mkdir(path.join(stage, 'lib'));
async function copyLibrary(directory, name) {
const library = await fs.readFile(path.join(directory, name));
if (
library.length < 52 ||
library.subarray(0, 4).toString('hex') !== '7f454c46' ||
library[4] !== 1 ||
library[5] !== 1 ||
library.readUInt16LE(18) !== 40 ||
(library.readUInt32LE(36) & 0x400) !== 0
) {
throw new Error(`Not an ARM soft-float webOS library: ${name}`);
}
if (/^libSDL2(?:-2\.0)?\.so(?:\.[0-9]+)*$/.test(name)) verifyWebosSdl(library);
await fs.writeFile(path.join(stage, 'lib', name), library);
}
// Explicitly selected ARM libraries can override the pinned SDL-webOS runtime.
if (process.env.WEBOS_NATIVE_LIBS) {
const directory = path.resolve(process.env.WEBOS_NATIVE_LIBS);
for (const name of await fs.readdir(directory)) {
if (!/\.so(?:\.[0-9]+)*$/.test(name)) continue;
await copyLibrary(directory, name);
}
}
const sdlName = 'libSDL2-2.0.so.0';
if (!(await fs.readdir(path.join(stage, 'lib'))).includes(sdlName)) {
const runtime = await prepareWebosRuntime();
await copyLibrary(path.join(runtime, 'lib'), sdlName);
}
await fs.cp(path.join(root, 'assets/licenses'), path.join(stage, 'licenses'), { recursive: true });
await fs.writeFile(path.join(stage, 'appinfo.json'), `${JSON.stringify(manifest, null, 2)}\n`);
const state = path.join(out, 'cli-state');
await fs.mkdir(state, { recursive: true });
const result = spawnSync(
process.execPath,
[
path.join(root, 'node_modules/@webos-tools/cli/bin/ares-package.js'),
'--no-minify',
'--outdir',
dist,
stage,
],
{ cwd: root, env: { ...process.env, APPDATA: state }, stdio: 'inherit', timeout: 120000 },
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
const filename = `${manifest.id}_${manifest.version}_arm.ipk`;
const packaged = normalizeAndVerifyPackage(await fs.readFile(path.join(dist, filename)), manifest);
await fs.writeFile(path.join(dist, filename), packaged);
const hash = createHash('sha256').update(packaged).digest('hex');
await fs.writeFile(path.join(dist, 'sha256sums'), `${hash} ${filename}\n`);
console.log(`Ready to install: dist/${filename}\nSHA-256: ${hash}`);
+60
View File
@@ -0,0 +1,60 @@
function versionParts(version) {
if (typeof version !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
throw new Error(`Invalid numeric release version: ${version}`);
}
return version.split('.').map(BigInt);
}
export function isVersionIncrease(previous, current) {
const before = versionParts(previous);
const after = versionParts(current);
for (let index = 0; index < before.length; index++) {
if (before[index] !== after[index]) return after[index] > before[index];
}
return false;
}
export async function isReleaseMerge({ github, context }) {
if (
context.eventName !== 'push' ||
context.ref !== 'refs/heads/main' ||
context.payload.deleted ||
context.payload.forced ||
!context.payload.before ||
/^0+$/.test(context.payload.before)
) {
return false;
}
const repository = `${context.repo.owner}/${context.repo.repo}`;
const pulls = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
...context.repo,
commit_sha: context.sha,
per_page: 100,
});
const release = pulls.find(
(pull) =>
pull.merged_at &&
pull.merge_commit_sha === context.sha &&
pull.base.ref === 'main' &&
pull.base.repo?.full_name === repository &&
pull.head.ref === 'changeset-release/main' &&
pull.head.repo?.full_name === repository,
);
if (!release) return false;
const versions = await Promise.all(
[context.payload.before, context.sha].map(async (ref) => {
const { data } = await github.rest.repos.getContent({
...context.repo,
ref,
path: 'package.json',
});
if (data.encoding !== 'base64' || typeof data.content !== 'string') {
throw new Error(`Could not read package.json at ${ref}.`);
}
return JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')).version;
}),
);
return isVersionIncrease(...versions);
}
+63
View File
@@ -0,0 +1,63 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
const root = path.resolve(import.meta.dirname, '..');
const manifest = JSON.parse(await fs.readFile(path.join(root, 'appinfo.json'), 'utf8'));
const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
if (!/^\d+\.\d+\.\d+$/.test(manifest.version) || pkg.version !== manifest.version)
throw new Error('package.json and appinfo.json must have the same numeric release version.');
const tag = `v${manifest.version}`;
const requestedTag =
process.env.RELEASE_TAG ||
(process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : '');
if (requestedTag && requestedTag !== tag)
throw new Error(`Release tag must be ${tag}, matching the packaged app version.`);
const repository = process.env.GITHUB_REPOSITORY || 'ecklf/lg-luma';
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error('Invalid GitHub repository name.');
const sourceUrl = `https://github.com/${repository}`;
const releaseUrl = `${sourceUrl}/releases/download/${tag}`;
const filename = `${manifest.id}_${manifest.version}_arm.ipk`;
const output = path.join(root, 'dist');
const bytes = await fs.readFile(path.join(output, filename));
const iconUri = `${releaseUrl}/${manifest.largeIcon}`;
const descriptionFilename = 'description.html';
const shortDescription = manifest.appDescription?.trim();
if (!shortDescription) throw new Error('appinfo.json must provide a non-empty appDescription.');
const feed = {
paging: { page: 1, count: 1, maxPage: 1, itemsTotal: 1 },
packages: [
{
id: manifest.id,
title: manifest.title,
shortDescription,
fullDescriptionUrl: `${releaseUrl}/${descriptionFilename}`,
iconUri,
manifest: {
id: manifest.id,
version: manifest.version,
type: manifest.type,
title: manifest.title,
appDescription: manifest.appDescription,
iconUri,
sourceUrl,
rootRequired: true,
ipkUrl: `${releaseUrl}/${filename}`,
ipkHash: { sha256: createHash('sha256').update(bytes).digest('hex') },
ipkSize: bytes.length,
},
},
],
};
await fs.copyFile(
path.join(root, '.build/package', manifest.largeIcon),
path.join(output, manifest.largeIcon),
);
await fs.copyFile(
path.join(root, 'assets/homebrew-description.html'),
path.join(output, descriptionFilename),
);
await fs.writeFile(path.join(output, 'repo.json'), JSON.stringify(feed, null, 2) + '\n');
if (process.env.GITHUB_OUTPUT) await fs.appendFile(process.env.GITHUB_OUTPUT, `tag=${tag}\n`);
console.log(`Homebrew feed: dist/repo.json (${tag}, ${bytes.length} byte IPK)`);
+63
View File
@@ -0,0 +1,63 @@
// Capture documentation from the actual native desktop renderer, not a browser.
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = path.resolve(import.meta.dirname, '..');
const docs = path.join(root, 'docs');
const scratch = path.join(root, '.build');
await fs.mkdir(docs, { recursive: true });
await fs.mkdir(scratch, { recursive: true });
function run(command, args) {
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', timeout: 120000 });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status}`);
}
// Force the desktop feature set even after a previous all-features lint/build.
run('cargo', ['build', '--locked', '--package', 'luma', '--no-default-features']);
const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma');
const temporary = await fs.mkdtemp(path.join(scratch, 'documentation-'));
const preferences = path.join(temporary, 'preferences.json');
const configuration = {
language: 'en',
background: 'dunes',
hour_24: false,
show_home_names: false,
show_weather: true,
weather_location: null,
hidden: [],
input_modes: {},
reduced_motion: false,
};
try {
for (const [name, script] of [
['home', 'home,down'],
['apps', 'apps,down'],
['settings', 'settings,down'],
['inputs', 'settings,down,down,down,down,enter'],
]) {
// Independent state makes screenshots stable regardless of capture order.
await fs.writeFile(preferences, JSON.stringify(configuration));
run(binary, [
'--assets',
path.join(scratch, 'assets'),
'--preferences',
preferences,
'--time',
'2026-10-22T20:41:00+02:00',
'--script',
script,
'--frames',
'90',
'--capture-scale',
'1',
'--screenshot',
path.join(docs, `${name}.png`),
]);
console.log(`Native screenshot: docs/${name}.png (1920×1080)`);
}
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
+48
View File
@@ -0,0 +1,48 @@
// Pinned host toolchains. SDL-webOS is prepared separately by webos-runtime.mjs.
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = path.resolve(import.meta.dirname, '..');
const parent = path.join(root, '.build/toolchain');
const sdk = path.join(parent, 'arm-webos-linux-gnueabi_sdk-buildroot');
const assets = {
'darwin-arm64':
'https://github.com/webosbrew/native-toolchain/releases/download/webos-d7ed7ee.6/arm-webos-linux-gnueabi_sdk-buildroot_darwin-arm64.tar.bz2',
'darwin-x64':
'https://github.com/webosbrew/native-toolchain/releases/download/webos-d7ed7ee.6/arm-webos-linux-gnueabi_sdk-buildroot_darwin-x86_64.tar.bz2',
'linux-x64':
'https://github.com/openlgtv/buildroot-nc4/releases/download/webos-b17b4cc/arm-webos-linux-gnueabi_sdk-buildroot.tar.gz',
'linux-arm64':
'https://github.com/webosbrew/native-toolchain/releases/download/webos-d7ed7ee.6/arm-webos-linux-gnueabi_sdk-buildroot_linux-aarch64.tar.bz2',
};
const url = assets[`${process.platform}-${process.arch}`];
if (!url) throw new Error(`No pinned webOS SDK for ${process.platform}/${process.arch}.`);
if (/[\s]/.test(sdk)) throw new Error('Extract the webOS SDK in a path without spaces.');
await fs.mkdir(parent, { recursive: true });
try {
await fs.access(path.join(sdk, 'bin/arm-webos-linux-gnueabi-gcc'));
} catch {
const archive = path.join(parent, path.basename(url));
const downloaded = spawnSync(
'curl',
['--fail', '--location', '--retry', '3', '--silent', '--show-error', '--output', archive, url],
{ stdio: 'inherit', timeout: 300000 },
);
if (downloaded.error) throw downloaded.error;
if (downloaded.status !== 0) throw new Error('webOS SDK download failed.');
const extracted = spawnSync(
'tar',
[url.endsWith('.bz2') ? '-xjf' : '-xzf', archive, '-C', parent],
{ stdio: 'inherit', timeout: 120000 },
);
if (extracted.error) throw extracted.error;
if (extracted.status !== 0) throw new Error('webOS SDK extraction failed.');
}
const relocated = spawnSync('sh', [path.join(sdk, 'relocate-sdk.sh')], {
stdio: 'inherit',
timeout: 120000,
});
if (relocated.error) throw relocated.error;
if (relocated.status !== 0) throw new Error('webOS SDK relocation failed.');
console.log(`webOS SDK ready. Set WEBOS_NDK=${sdk}`);
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(import.meta.dirname, '..');
function replaceVersion(source, pattern, version, filename) {
if ([...source.matchAll(pattern)].length !== 1) {
throw new Error(`Expected one release version in ${filename}.`);
}
return source.replace(pattern, (_match, prefix, suffix) => `${prefix}${version}${suffix}`);
}
function updateTomlVersion(source, header, version, filename) {
let packages = 0;
const updated = source
.split(/(?=^\[)/m)
.map((section) => {
if (
section.split(/\r?\n/, 1)[0].trim() !== header ||
!/^name[ \t]*=[ \t]*"luma"[ \t]*\r?$/m.test(section)
) {
return section;
}
packages++;
return replaceVersion(
section,
/^([ \t]*version[ \t]*=[ \t]*")[^"\r\n]+(")/gm,
version,
filename,
);
})
.join('');
if (packages !== 1) throw new Error(`Expected one luma package in ${filename}.`);
return updated;
}
export async function syncVersions(directory = root, { check = false } = {}) {
const read = (filename) => fs.readFile(path.join(directory, filename), 'utf8');
const { version } = JSON.parse(await read('package.json'));
if (typeof version !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
throw new Error('package.json must have a numeric major.minor.patch release version.');
}
const filenames = ['appinfo.json', 'Cargo.toml', 'Cargo.lock', 'readme.md'];
const sources = await Promise.all(filenames.map(read));
JSON.parse(sources[0]);
const ipkPattern = /\bcom\.ecklf\.luma_\d+\.\d+\.\d+_arm\.ipk\b/g;
if (![...sources[3].matchAll(ipkPattern)].length) {
throw new Error('Expected versioned Luma IPK examples in readme.md.');
}
const updated = [
replaceVersion(
sources[0],
/^([ \t]*"version"[ \t]*:[ \t]*")[^"\r\n]+(")/gm,
version,
'appinfo.json',
),
updateTomlVersion(sources[1], '[package]', version, 'Cargo.toml'),
updateTomlVersion(sources[2], '[[package]]', version, 'Cargo.lock'),
sources[3].replace(ipkPattern, `com.ecklf.luma_${version}_arm.ipk`),
];
const changed = filenames.filter((_filename, index) => sources[index] !== updated[index]);
if (check && changed.length) {
throw new Error(
`Release versions are out of sync: ${changed.join(', ')}. Run pnpm version:sync.`,
);
}
if (!check) {
await Promise.all(
filenames.map((filename, index) =>
sources[index] === updated[index]
? undefined
: fs.writeFile(path.join(directory, filename), updated[index]),
),
);
}
return { version, changed };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const check = process.argv.includes('--check');
const { version, changed } = await syncVersions(root, { check });
console.log(
check
? `Release versions match ${version}.`
: `Synchronized ${version}: ${changed.join(', ') || 'no changes'}.`,
);
}
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const root = path.resolve(import.meta.dirname, '..');
const out = path.join(root, '.build/assets');
const sources = path.join(root, '.build/vector-sources');
export async function generateVectorAssets() {
await fs.mkdir(sources, { recursive: true });
const assets = [];
for (const file of await fs.readdir(path.join(root, 'assets/logos'))) {
if (!file.endsWith('.svg')) continue;
const name = path.parse(file).name;
const expanded = ![
'apple-tv',
'disney-plus',
'prime-video',
'das-erste',
'netflix',
'youtube',
'arte',
'zdf',
'jellyfin',
].includes(name);
assets.push({
source: path.join(root, 'assets/logos', file),
output: path.join(out, 'logos', `${name}.lvec`),
monochrome: [
'apple-tv',
'disney-plus',
'prime-video',
'das-erste',
'plex',
'hbo-max',
'paramount-plus',
'twitch',
'dazn',
'rakuten-tv',
'apple-music',
'amazon-music',
'bbc-iplayer',
'itvx',
'channel-4',
'now-tv',
'joyn',
'waipu-tv',
'zattoo',
'skyshowtime',
'movistar-plus',
'nrk-tv',
].includes(name),
light_ink: expanded && !['live-tv', 'browser'].includes(name),
trim: expanded,
reference_size: 1536,
});
}
// Standalone SVG sources preserve the shipped Lucide and input artwork.
// No React renderer, JSX loader or JavaScript icon dependency is needed.
for (const file of await fs.readdir(path.join(root, 'assets/ui-icons'))) {
if (!file.endsWith('.svg')) continue;
const name = path.parse(file).name;
assets.push({
source: path.join(root, 'assets/ui-icons', file),
output: path.join(out, 'ui-icons', `${name}.lvec`),
reference_size: 256,
});
}
const manifest = path.join(sources, 'manifest.json');
await fs.writeFile(manifest, JSON.stringify(assets));
const result = spawnSync(
'cargo',
[
'run',
'--locked',
'--release',
'--manifest-path',
path.join(root, 'tools/vector-assets/Cargo.toml'),
'--',
manifest,
],
{
cwd: root,
env: {
...process.env,
CARGO_TARGET_DIR: path.join(root, '.build/vector-assets-target'),
},
stdio: 'inherit',
timeout: 120000,
},
);
if (result.error) throw result.error;
if (result.status !== 0) throw new Error('Native SVG geometry compilation failed.');
await fs.writeFile(
path.join(out, 'vector-artwork.json'),
JSON.stringify({
version: 1,
assets: assets.map((asset) => path.relative(out, asset.output)),
}),
);
// These directories contain generated artwork only. Do not package stale raster
// versions alongside the meshes when reusing an older native asset cache.
for (const directory of ['logos', 'ui-icons']) {
for (const file of await fs.readdir(path.join(out, directory))) {
if (file.endsWith('.png')) await fs.rm(path.join(out, directory, file));
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
await generateVectorAssets();
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "luma-vector-assets"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
license = "MIT"
[dependencies]
luma-vector-geometry = { path = "../../vector-geometry" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
usvg = { version = "0.45", default-features = false }
+462
View File
@@ -0,0 +1,462 @@
//! Host-only SVG compiler. No XML parser or tessellator is linked into the TV app.
use luma_vector_geometry as geometry;
use luma_vector_geometry::lyon_tessellation::{
FillRule,
math::{Point, point},
path::Path,
};
use serde::Deserialize;
use std::{fs, io::Write, path::PathBuf};
use usvg::{
Paint,
tiny_skia_path::{PathSegment, Transform},
};
const MAGIC: &[u8; 8] = b"LUMAVEC1";
const MAX_VERTICES: usize = 1_000_000;
#[derive(Deserialize)]
struct Asset {
source: PathBuf,
output: PathBuf,
#[serde(default)]
monochrome: bool,
#[serde(default)]
light_ink: bool,
#[serde(default)]
trim: bool,
reference_size: f32,
}
#[derive(Clone, Copy)]
struct Vertex {
position: Point,
offset: [f32; 2],
coverage: f32,
}
impl Vertex {
fn midpoint(self, other: Self) -> Self {
Self {
position: (self.position + other.position.to_vector()) * 0.5,
offset: [
(self.offset[0] + other.offset[0]) * 0.5,
(self.offset[1] + other.offset[1]) * 0.5,
],
coverage: (self.coverage + other.coverage) * 0.5,
}
}
}
struct Painter<'a> {
paint: &'a Paint,
inverse: Transform,
opacity: f32,
monochrome: bool,
light_ink: bool,
}
impl Painter<'_> {
fn color(&self, position: Point) -> Result<[f32; 4], String> {
let mut p = usvg::tiny_skia_path::Point::from_xy(position.x, position.y);
self.inverse.map_point(&mut p);
let (base, stops, t): (&usvg::BaseGradient, &[usvg::Stop], f32) = match self.paint {
Paint::Color(color) => {
return Ok(self.finish(*color, self.opacity));
}
Paint::LinearGradient(gradient) => {
gradient
.transform()
.invert()
.ok_or("Singular SVG gradient transform")?
.map_point(&mut p);
let dx = gradient.x2() - gradient.x1();
let dy = gradient.y2() - gradient.y1();
let length = dx * dx + dy * dy;
let t = if length > 0. {
((p.x - gradient.x1()) * dx + (p.y - gradient.y1()) * dy) / length
} else {
0.
};
(&**gradient, gradient.stops(), t)
}
Paint::RadialGradient(gradient) => {
gradient
.transform()
.invert()
.ok_or("Singular SVG gradient transform")?
.map_point(&mut p);
let dx = p.x - gradient.fx();
let dy = p.y - gradient.fy();
let fx = gradient.fx() - gradient.cx();
let fy = gradient.fy() - gradient.cy();
let a = dx * dx + dy * dy;
let b = 2. * (fx * dx + fy * dy);
let c = fx * fx + fy * fy - gradient.r().get().powi(2);
let discriminant = (b * b - 4. * a * c).max(0.);
let lambda = if a > 0. {
(-b + discriminant.sqrt()) / (2. * a)
} else {
0.
};
let t = if lambda > 0. { 1. / lambda } else { 0. };
(&**gradient, gradient.stops(), t)
}
Paint::Pattern(_) => {
return Err("SVG patterns are not supported by vector artwork".into());
}
};
let t = match base.spread_method() {
usvg::SpreadMethod::Pad => t.clamp(0., 1.),
usvg::SpreadMethod::Repeat => t.rem_euclid(1.),
usvg::SpreadMethod::Reflect => {
let t = t.rem_euclid(2.);
if t > 1. { 2. - t } else { t }
}
};
let first = stops.first().ok_or("SVG gradient has no stops")?;
let last = stops.last().unwrap();
if t <= first.offset().get() {
return Ok(self.finish(first.color(), self.opacity * first.opacity().get()));
}
for pair in stops.windows(2) {
if t <= pair[1].offset().get() {
let a = self.finish(pair[0].color(), self.opacity * pair[0].opacity().get());
let b = self.finish(pair[1].color(), self.opacity * pair[1].opacity().get());
let span = pair[1].offset().get() - pair[0].offset().get();
let t = if span > 0. {
(t - pair[0].offset().get()) / span
} else {
1.
};
return Ok(std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t));
}
}
Ok(self.finish(last.color(), self.opacity * last.opacity().get()))
}
fn finish(&self, color: usvg::Color, opacity: f32) -> [f32; 4] {
if self.monochrome || (self.light_ink && color.red.max(color.green).max(color.blue) < 80) {
[1., 1., 1., opacity]
} else {
[
color.red as f32 / 255.,
color.green as f32 / 255.,
color.blue as f32 / 255.,
opacity,
]
}
}
}
struct Compiler {
vertices: Vec<[f32; 9]>,
tolerance: f32,
reference_scale: f32,
monochrome: bool,
light_ink: bool,
}
impl Compiler {
fn group(&mut self, group: &usvg::Group) -> Result<(), String> {
if group.clip_path().is_some() || group.mask().is_some() || !group.filters().is_empty() {
return Err("Clips, masks and filters are not supported by vector artwork".into());
}
if group.opacity().get() != 1. {
return Err(
"Group opacity needs isolated compositing; use path opacity in vector artwork"
.into(),
);
}
for node in group.children() {
match node {
usvg::Node::Group(group) => self.group(group)?,
usvg::Node::Path(path) if path.is_visible() => match path.paint_order() {
usvg::PaintOrder::FillAndStroke => {
self.fill(path)?;
self.stroke(path)?;
}
usvg::PaintOrder::StrokeAndFill => {
self.stroke(path)?;
self.fill(path)?;
}
},
usvg::Node::Path(_) => {}
_ => {
return Err(
"Vector artwork must contain paths, not embedded images or text".into(),
);
}
}
}
Ok(())
}
fn fill(&mut self, path: &usvg::Path) -> Result<(), String> {
if let Some(fill) = path.fill() {
let rule = match fill.rule() {
usvg::FillRule::NonZero => FillRule::NonZero,
usvg::FillRule::EvenOdd => FillRule::EvenOdd,
};
self.path(
path.data(),
path.abs_transform(),
fill.paint(),
fill.opacity().get(),
rule,
)?;
}
Ok(())
}
fn stroke(&mut self, path: &usvg::Path) -> Result<(), String> {
if let Some(stroke) = path.stroke() {
let transform = path.abs_transform();
let scale = self.reference_scale
* (transform.sx.hypot(transform.ky)).max(transform.kx.hypot(transform.sy));
let style = stroke.to_tiny_skia();
let data = match &style.dash {
Some(dash) => path
.data()
.dash(dash, scale)
.ok_or("Could not resolve dashed SVG stroke")?,
None => path.data().clone(),
};
if let Some(outline) = data.stroke(&style, scale) {
self.path(
&outline,
transform,
stroke.paint(),
stroke.opacity().get(),
FillRule::NonZero,
)?;
}
}
Ok(())
}
fn path(
&mut self,
data: &usvg::tiny_skia_path::Path,
transform: Transform,
paint: &Paint,
opacity: f32,
rule: FillRule,
) -> Result<(), String> {
let painter = Painter {
paint,
inverse: transform.invert().ok_or("Singular SVG path transform")?,
opacity,
monochrome: self.monochrome,
light_ink: self.light_ink,
};
let data = data
.clone()
.transform(transform)
.ok_or("Invalid SVG path transform")?;
let mut builder = Path::builder();
let mut open = false;
for segment in data.segments() {
match segment {
PathSegment::MoveTo(p) => {
if open {
builder.end(true);
}
builder.begin(point(p.x, p.y));
open = true;
}
PathSegment::LineTo(p) => {
builder.line_to(point(p.x, p.y));
}
PathSegment::QuadTo(a, b) => {
builder.quadratic_bezier_to(point(a.x, a.y), point(b.x, b.y));
}
PathSegment::CubicTo(a, b, c) => {
builder.cubic_bezier_to(point(a.x, a.y), point(b.x, b.y), point(c.x, c.y));
}
PathSegment::Close => {
if open {
builder.end(true);
open = false;
}
}
}
}
if open {
builder.end(true);
}
let path = builder.build();
for triangle in geometry::tessellate(&path, self.tolerance, rule)?.chunks_exact(3) {
self.triangle(
std::array::from_fn(|index| Vertex {
position: triangle[index].position,
offset: triangle[index].offset,
coverage: triangle[index].coverage,
}),
&painter,
0,
)?;
}
Ok(())
}
fn triangle(
&mut self,
vertices: [Vertex; 3],
painter: &Painter,
depth: usize,
) -> Result<(), String> {
let colors = [
painter.color(vertices[0].position)?,
painter.color(vertices[1].position)?,
painter.color(vertices[2].position)?,
];
if !matches!(painter.paint, Paint::Color(_)) && depth < 8 {
let midpoints = [
vertices[0].midpoint(vertices[1]),
vertices[1].midpoint(vertices[2]),
vertices[2].midpoint(vertices[0]),
];
let mut error = 0f32;
let center = point(
vertices.iter().map(|vertex| vertex.position.x).sum::<f32>() / 3.,
vertices.iter().map(|vertex| vertex.position.y).sum::<f32>() / 3.,
);
let actual = painter.color(center)?;
for channel in 0..4 {
error = error.max(
(actual[channel] - colors.iter().map(|color| color[channel]).sum::<f32>() / 3.)
.abs(),
);
}
for (i, midpoint) in midpoints.iter().enumerate() {
let actual = painter.color(midpoint.position)?;
for channel in 0..4 {
error = error.max(
(actual[channel]
- (colors[i][channel] + colors[(i + 1) % 3][channel]) * 0.5)
.abs(),
);
}
}
if error > 1. / 512. {
for triangle in [
[vertices[0], midpoints[0], midpoints[2]],
[midpoints[0], vertices[1], midpoints[1]],
[midpoints[2], midpoints[1], vertices[2]],
[midpoints[0], midpoints[1], midpoints[2]],
] {
self.triangle(triangle, painter, depth + 1)?;
}
return Ok(());
}
}
if self.vertices.len() + 3 > MAX_VERTICES {
return Err("Vector artwork exceeds its geometry budget".into());
}
for (vertex, color) in vertices.into_iter().zip(colors) {
self.vertices.push([
vertex.position.x,
vertex.position.y,
vertex.offset[0],
vertex.offset[1],
color[0],
color[1],
color[2],
color[3],
vertex.coverage,
]);
}
Ok(())
}
}
fn run() -> Result<(), String> {
let manifest = std::env::args()
.nth(1)
.ok_or("Pass the vector artwork manifest")?;
let assets: Vec<Asset> =
serde_json::from_slice(&fs::read(manifest).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let mut total = 0;
for asset in &assets {
let data =
fs::read(&asset.source).map_err(|e| format!("{}: {e}", asset.source.display()))?;
let tree = usvg::Tree::from_data(&data, &usvg::Options::default())
.map_err(|e| format!("{}: {e}", asset.source.display()))?;
let size = tree.size();
let scale = asset.reference_size / size.width().max(size.height());
let mut compiler = Compiler {
vertices: Vec::new(),
tolerance: 0.15 / scale,
reference_scale: scale,
monochrome: asset.monochrome,
light_ink: asset.light_ink,
};
compiler
.group(tree.root())
.map_err(|e| format!("{}: {e}", asset.source.display()))?;
if compiler.vertices.is_empty() {
return Err(format!("No vector geometry in {}", asset.source.display()));
}
let (width, height) = if asset.trim {
let bounds = compiler.vertices.iter().fold(
[
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
],
|b, v| {
[
b[0].min(v[0]),
b[1].min(v[1]),
b[2].max(v[0]),
b[3].max(v[1]),
]
},
);
for vertex in &mut compiler.vertices {
vertex[0] -= bounds[0];
vertex[1] -= bounds[1];
}
(bounds[2] - bounds[0], bounds[3] - bounds[1])
} else {
(size.width(), size.height())
};
let mut bytes = Vec::with_capacity(20 + compiler.vertices.len() * 36);
bytes.extend_from_slice(MAGIC);
bytes.extend_from_slice(&width.to_le_bytes());
bytes.extend_from_slice(&height.to_le_bytes());
bytes.extend_from_slice(&(compiler.vertices.len() as u32).to_le_bytes());
for vertex in &compiler.vertices {
for value in vertex {
if !value.is_finite() {
return Err(format!(
"Non-finite vector geometry in {}",
asset.source.display()
));
}
bytes.extend_from_slice(&value.to_le_bytes());
}
}
fs::create_dir_all(asset.output.parent().ok_or("Invalid vector output path")?)
.map_err(|e| e.to_string())?;
let mut file = fs::File::create(&asset.output).map_err(|e| e.to_string())?;
file.write_all(&bytes).map_err(|e| e.to_string())?;
total += bytes.len();
}
println!(
"Compiled {} SVG assets into cached vector geometry ({:.2} MiB).",
assets.len(),
total as f64 / 1048576.
);
Ok(())
}
fn main() {
if let Err(error) = run() {
eprintln!("Vector artwork: {error}");
std::process::exit(1);
}
}
+96
View File
@@ -0,0 +1,96 @@
// SDL in the NDK is for linking, not TV display: its Wayland backend is disabled.
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const root = path.resolve(import.meta.dirname, '..');
const directory = path.join(root, '.build/sdl-webos');
const release = 'release-2.30.12-webos.5';
const filename = 'SDL2-2.30.12-webos.tar.gz';
const archiveHash = '4ad566453d113bdd9ee96878176b97d28d9fa70503a62d83d55a351545abb334';
const libraryHash = 'f564f7da624cec47168300d017f3ec822f41b5096f0ce1c96873eecd8614d446';
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
export function verifyWebosSdl(bytes) {
if (
bytes.length < 52 ||
bytes.subarray(0, 4).toString('hex') !== '7f454c46' ||
bytes[4] !== 1 ||
bytes[5] !== 1 ||
bytes.readUInt16LE(18) !== 40 ||
bytes.readUInt16LE(16) !== 3 ||
(bytes.readUInt32LE(36) & 0x400) !== 0
) {
throw new Error('SDL runtime must be an ARM soft-float shared library.');
}
// These are compiled backend strings, not merely exported SDL_webOS API stubs.
if (
!bytes.includes(Buffer.from('SDL Wayland video driver\0')) ||
!bytes.includes(Buffer.from('APPID environment variable is not set\0'))
) {
throw new Error('SDL has no webOS Wayland video backend. Do not package the SDK stub SDL.');
}
}
export async function prepareWebosRuntime() {
await fs.mkdir(directory, { recursive: true });
const archive = path.join(directory, filename);
let bytes;
try {
bytes = await fs.readFile(archive);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
console.log(`Downloading SDL-webOS ${release} (standard SDL ABI).`);
const url = `https://github.com/webosbrew/SDL-webOS/releases/download/${release}/${filename}`;
const response = await fetch(url, { signal: AbortSignal.timeout(60000) });
if (!response.ok) throw new Error(`SDL-webOS download failed: ${response.status}`);
bytes = Buffer.from(await response.arrayBuffer());
if (hash(bytes) !== archiveHash) throw new Error('SDL-webOS archive checksum mismatch.');
await fs.writeFile(archive, bytes);
}
if (hash(bytes) !== archiveHash) throw new Error('SDL-webOS archive checksum mismatch.');
const library = path.join(directory, 'lib/libSDL2-2.0.so.0');
let current;
try {
current = await fs.readFile(library);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (!current || hash(current) !== libraryHash) {
const result = spawnSync('tar', ['-xzf', archive, '-C', directory], {
stdio: 'inherit',
timeout: 60000,
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error('SDL-webOS extraction failed.');
current = await fs.readFile(library);
}
if (hash(current) !== libraryHash) throw new Error('SDL-webOS library checksum mismatch.');
verifyWebosSdl(current);
// Upstream metadata embeds /usr/local and an rpath. Generate relocatable build-host
// lookup metadata instead; the TV executable keeps only its $ORIGIN/lib rpath.
const pkgconfig = path.join(directory, 'pkgconfig');
await fs.mkdir(pkgconfig, { recursive: true });
await fs.writeFile(
path.join(pkgconfig, 'sdl2.pc'),
`prefix=${directory}
libdir=\${prefix}/lib
includedir=\${prefix}/include/SDL2
Name: sdl2
Description: SDL-webOS with the Wayland/webOS backend and standard SDL ABI
Version: 2.30.12
Libs: -L\${libdir} -lSDL2
Cflags: -I\${includedir} -D_REENTRANT
`,
);
console.log(`Verified SDL-webOS ${release}: ARM soft-float, Wayland/webOS backend.`);
return directory;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
await prepareWebosRuntime();
}