Files
Kaloyan DanchevandClaude Opus 4.6 cbea5a322f
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
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>
2026-10-24 11:52:55 +03:00

188 lines
7.2 KiB
JavaScript

// 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);
}