Merge Luma launcher fork into monorepo
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:
1 parent
bc3ca0f3b9
commit
cbea5a322f
204 files changed
+25056
-65
No files matched your search
@@ -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)}`);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)`);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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'}.`,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user