diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..af346f0 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "samsung-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:samsung"], + "port": 4301 + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index aef5692..304182f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ A build is one **cell**: `nx build samsung --customer=ccl --profile=tizen6`. | `platforms//` | Packaging inputs: `profiles/*.json` (capabilities) + `templates/` (container manifests). **Not app code.** | | `customers//` | Per-cruiseline content: `config.json` (sectioned), `layouts/`, `i18n/`, `assets/`. | | `tools/` | Build tooling: `executors/build-tv.mjs`, `packaging/` (package-tv, signing, customer-slug), `vite/xtv-aliases.ts`. | -| `docs/` | `signing.md` (manual signing), `config-hot-reload.md` (live config, no reboot), `state-and-storage.md` (state layers + persistence), `tv-platform-reference.md` (keycodes + device APIs), `DEV-PLAYBOOK.md` (skills/workflow). | +| `docs/` | `signing.md` (manual signing), `config-hot-reload.md` (live config, no reboot), `theming.md` (config-driven themes), `state-and-storage.md` (state layers + persistence), `tv-platform-reference.md` (keycodes + device APIs), `DEV-PLAYBOOK.md` (skills/workflow). | | `signing/` | `signing.example.json` (template). Real creds in gitignored `.signing.local.json`. | ### Key libs @@ -117,6 +117,13 @@ Sign a build by exporting `XTV_CCL_*` env (see `docs/signing.md`) before `build` service-gateway — localStorage is best-effort only (TV quota; wiped on update/uninstall). zustand was removed (unused; Blits covers reactive state). Full model: `docs/state-and-storage.md`. +10. **Release version is build-time, not runtime.** Each cruiseline's version lives + in `customers//release.json` (engineering-owned; gradle-style versionName, + versionCode derived) — baked into the artifact (config.xml/appinfo version, `.wgt` + name, Android versionCode). **NOT** in `config.json` (that's deployment/head-end- + owned + runtime-overridable). Packager precedence: `--version` > `XTV_APP_VERSION` + (CI) > `release.json` > root `package.json`. Don't pass `--version` through + `nx build` — nx swallows it; use `XTV_APP_VERSION` for CI. ## How to… diff --git a/customers/ccl/config.json b/customers/ccl/config.json index 3ba5f67..3e6d79d 100644 --- a/customers/ccl/config.json +++ b/customers/ccl/config.json @@ -2,7 +2,7 @@ "runtime": { "customer": "ccl", "locale": "en", - "theme": "ccl-red", + "theme": "carnival-day", "features": { "remoteLayouts": true, "websocketEvents": true, @@ -20,8 +20,8 @@ "integrations": { "runtime": { "customerId": "ccl", - "layoutSource": "liferay", - "contentSource": "liferay" + "layoutSource": "local", + "contentSource": "local" }, "liferayApi": { "baseUrl": "https://portal.ccl.example.invalid/", diff --git a/customers/ccl/release.json b/customers/ccl/release.json new file mode 100644 index 0000000..1656b31 --- /dev/null +++ b/customers/ccl/release.json @@ -0,0 +1,4 @@ +{ + "_comment": "Engineering-owned release version for this cruiseline (gradle-style versionName). Baked into the artifact at build time — NOT runtime config, NOT head-end-overridable. CI may override via XTV_APP_VERSION. versionCode is derived from this.", + "version": "1.0.0" +} diff --git a/docs/signing.md b/docs/signing.md index e377611..a00c5cc 100644 --- a/docs/signing.md +++ b/docs/signing.md @@ -1,9 +1,10 @@ # Signing xTV Platform Artifacts -Every production TV artifact must be **signed** with the cruiseline's own -certificate. Signing is **cert-pluggable**: the build injects credentials when -they are available and otherwise emits an **unsigned** artifact plus a loud -warning (the build stays green so procurement is never a blocker). Secrets are +**Samsung** and **Android** production artifacts must be **signed** with the +cruiseline's own certificate; **LG/webOS does not** (an unsigned `.ipk` is +deployable — see §4). Signing is **cert-pluggable**: the build injects credentials +when available, otherwise emits an unsigned artifact + a warning (Samsung/Android +only) so the build stays green and procurement is never a blocker. Secrets are **never** committed. This guide covers both paths: @@ -25,7 +26,7 @@ The resolver `tools/packaging/signing.mjs` reads, per `(cruiseline, platform)`: | Platform | Variables | |---|---| | Samsung | `XTV__TIZEN_PROFILE` (Certificate Manager profile name), `XTV_TIZEN_CLI` (path to the `tizen` bin dir) | -| LG | `XTV__WEBOS_SIGN_KEY` (Pro:Centric signing cert), `XTV_WEBOS_CLI` (path to the ares bin dir) | +| LG | `XTV_WEBOS_CLI` (path to the ares bin dir). `XTV__WEBOS_SIGN_KEY` is **optional** — Pro:Centric SI signing only; not needed for dev/store. | | Android | `XTV__ANDROID_KEYSTORE`, `XTV__ANDROID_KS_PASS`, `XTV__ANDROID_KEY_ALIAS`, `XTV__ANDROID_KEY_PASS` | Example (CCL): @@ -76,43 +77,95 @@ tizen security-profiles add -n ccl-dev-2 \ -d /path/to/distributor.p12 -dp ``` -**Sign + package a stage:** +**Sign via the build (recommended)** — set the profile and the build signs the +`.wgt` with `tizen package -s`: ```bash -cd ~/.tizen-extension-platform/server/sdktools/data/tools/ide/bin -./tizen package -t wgt -s ccl-dev-2 -- \ +export XTV_CCL_TIZEN_PROFILE=ccl-dev-2 +npm run build:samsung -- --customer=ccl # signed .wgt + sssp_config.xml +npm run build:samsung -- --customer=ccl --sssp=1.01 # bump SSSP for a redeploy +``` + +> **App version** comes from `customers//release.json` (engineering-owned), +> or `XTV_APP_VERSION` for CI — it sets the `.wgt` name (`CCL_T65_`) and +> Android versionCode. Do **not** pass `--version` through `nx build` (nx swallows +> it). This is separate from the SSSP `` (`--sssp`), which only triggers the +> TV re-download. + +> ⚠️ **A real Samsung TV rejects an UNSIGNED `.wgt`** — the packager's zip +> fallback (no profile set) installs nowhere and shows **"unable to install custom +> app."** If install fails, this is almost always the cause. Verify: +> `unzip -l | grep -i signature` → must list `author-signature.xml` + +> `signature1.xml`. If empty, `XTV_CCL_TIZEN_PROFILE` wasn't set. + +**Or sign a stage manually:** +```bash +tizen package -t wgt -s ccl-dev-2 -- \ /abs/path/dist/platforms/samsung/ccl/tizen6/stage -# produces stage/.wgt — move it to artifacts/ and name it CCL_T6_.wgt +# move stage/.wgt → artifacts/CCL_T65_.wgt ``` **Deploy (SSSP / URL-Launcher):** copy **both** the `.wgt` and `sssp_config.xml` to the launcher folder the TV points at. `sssp_config.xml` `` must equal the -`.wgt` byte size (the build generates it correctly); bump `` to force a -re-download. +`.wgt` byte size (the build sets it); **bump `` (via `--sssp`) on every +redeploy** or the TV won't re-download a version it already tried. +> **DUID:** the distributor cert must include the target TV's DUID. A cert issued +> for one TV won't install on a different TV — re-issue the distributor cert with +> the new DUID. +> > Known limitation: even with a partner cert, `$B2BAPIS`/avplay may require the > MDC/B2B install channel rather than URL-Launcher download. Signing alone does > not grant it. --- -## 4. LG / webOS (`.ipk`) +## 4. LG / webOS (`.ipk`) — packaging & install + +**webOS does NOT need a Samsung-style distributor cert.** An unsigned `.ipk` is a +valid, deployable artifact: it installs on a Dev Mode TV as-is, and the Content +Store signs on submission. Pro:Centric (SI) signing is the only exception and is +optional — set `XTV_CCL_WEBOS_SIGN_KEY` to have the build apply it. **Prereqs:** webOS TV SDK (`ares-*` CLI), e.g. `/opt/webOS_TV_SDK/CLI/bin`. -**Package a stage:** +**Package a stage** (the build does this; manual equivalent): ```bash ares-package dist/platforms/lg/ccl/webos6/stage -o dist/platforms/lg/ccl/webos6/artifacts ``` -**Install to a dev TV / emulator:** -```bash -ares-setup-device # register the TV once -ares-install --device dist/platforms/lg/ccl/webos6/artifacts/*.ipk -``` - -**Pro:Centric (hospitality) signing:** the `.ipk` is signed with the LG SI / -Pro:Centric certificate using LG's tooling once the cert is provisioned. Set -`XTV_CCL_WEBOS_SIGN_KEY` so the automated build applies it. +### Dev install (Developer Mode TV) + +1. **Enable Developer Mode** (one-time): Content Store → install the **Developer + Mode** app → log in with your LG developer account → toggle **Dev Mode ON** + (TV reboots). The app shows the TV **IP**, port **9922**, and a **passphrase**. + Keep the TV on the same network. +2. **Register the TV** (one-time): + ```bash + ares-setup-device # add device: name, IP, port 9922 + ares-novacom --device --getkey # enter the Dev Mode passphrase + ``` +3. **Install the `.ipk`:** + ```bash + ares-install --device \ + dist/platforms/lg/ccl/webos6/artifacts/com.xcontrol.ccl.webos_0.1.0_all.ipk + ``` +4. **Launch** (app id = LG `app-identity`): + ```bash + ares-launch --device com.xcontrol.ccl.webos + ``` +5. **Debug (web inspector):** + ```bash + ares-inspect --device --app com.xcontrol.ccl.webos --open + ``` + +Housekeeping: `ares-install --device --list` / `--remove com.xcontrol.ccl.webos`. +Dev Mode session expires ~**50 hours** — reopen the Developer Mode app and extend. + +### Commercial / hospitality (fleet) + +Not `ares`. Pro:Centric TVs are provisioned centrally via the **Pro:Centric Smart / +SI server** (or USB commercial config), which pushes the app to cabins. This is the +XMM / head-end integration path — separate from dev sideload. --- diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 0000000..352b92e --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,44 @@ +# Theming + +Themes are **config-driven**: the tenant config picks one theme by id. Multiple +themes ship in the app; the config selects which one is active. (Device/group- +driven theming — different theme per balcony/lobby/cabin — comes later via XMM; +it's not wired in the client.) + +## Concept — "moments aboard" + +Carnival's funnel (red / white / blue) is the constant brand identity; each theme +shifts the *environment* (light + place). Brand anchors: Carnival Navy `#003C71`, +Carnival Red `#E4002B`, Sun Gold `#FFB81C`. + +| Theme id | Mood | +|---|---| +| `carnival-day` | bright atrium — sky-white bg, navy text, red accent | +| `carnival-sea` | ocean immersive — deep navy bg, white text, gold accent | +| `carnival-sunset` | golden hour — plum bg, cream text, coral accent | +| `carnival-night` | dark, low-glare — near-black navy, red glow accent | + +## Tokens + +Each theme is a `ThemeTokens` (`libs/themes`): `background`, `surface`, `text`, +`textMuted`, `accent`, `accentText`, `focus`. `focus` is the D-pad focus color — +always high-contrast on its background (TVs are navigated from a couch). Resolve +one with `getTheme(id)`. + +## Config + +In `customers//config.json` → `runtime`: + +```jsonc +"theme": "carnival-day" +``` + +`runtime-config` sets `runtimeConfig.theme` from this; the root app resolves it via +`getTheme(id)`, passes the tokens into components, and seeds it into +`this.$appState.theme`. Changing the theme is a config change (and takes effect on +a head-end config push, no rebuild). + +## Adding a theme + +Add a `ThemeTokens` in `libs/themes`, register it, set its id as a tenant's +`theme`. New brand = its own theme set; nothing else changes. diff --git a/libs/core/src/app.ts b/libs/core/src/app.ts index f74e7d1..45e5fa9 100644 --- a/libs/core/src/app.ts +++ b/libs/core/src/app.ts @@ -1,20 +1,30 @@ import Blits from "@lightningjs/blits"; -import { cclTheme } from "@x-tv/themes"; +import { getTheme } from "@x-tv/themes"; import { HelloWorld } from "@x-tv/widgets"; +import { getBootConfig } from "./boot-config"; -// Root Blits Application. Renders the Hello World screen; its caption reads -// global reactive state (this.$appState, seeded in core). Next: a config-driven, -// feature-gated multi-widget layout engine. +// Root Blits Application. Resolves the active theme (config-driven, per location) +// and passes its tokens down. Caption reads global reactive state (this.$appState). +// Next: a config-driven, feature-gated multi-widget layout engine. export default Blits.Application({ components: { HelloWorld }, template: ` - + `, state() { + const theme = getTheme(getBootConfig().theme); return { - background: cclTheme.colors.background, + background: theme.colors.background, + text: theme.colors.text, + textMuted: theme.colors.textMuted, + accent: theme.colors.accent, }; }, }); diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index a4bc8d9..f9244f4 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -97,20 +97,31 @@ export async function bootstrapTvPlatform( customer: runtimeConfig.customer, platform: runtimeConfig.platform.platform, locale: runtimeConfig.locale, + theme: runtimeConfig.theme, }); + // Mount diagnostics FIRST so its on-screen console is available even if the + // Blits launch fails on-device (unlock with the remote PIN). Errors then + // surface on the TV instead of a blank screen. + if (runtimeConfig.diagnostics.enabled) { + diagnostics.mount(); + } + // Launch the Blits (LightningJS canvas) app into #app. The font set is // tenant-driven (customers//config.json `fonts`), served from the // tenant public dir with relative paths so they resolve under file://. - Blits.Launch(App, "app", { - w: 1920, - h: 1080, - debugLevel: 1, - defaultFont: runtimeConfig.fonts.default, - fonts: runtimeConfig.fonts.families, - } as Parameters[2]); - if (runtimeConfig.diagnostics.enabled) { - diagnostics.mount(); + // multithreaded:false — the renderer worker fails under file:// on some TVs. + try { + Blits.Launch(App, "app", { + w: 1920, + h: 1080, + debugLevel: 1, + multithreaded: false, + defaultFont: runtimeConfig.fonts.default, + fonts: runtimeConfig.fonts.families, + } as Parameters[2]); + } catch (error) { + console.error("Blits.Launch failed", error); } connectLiveConfig(); connectMuting(); diff --git a/libs/runtime-config/src/index.ts b/libs/runtime-config/src/index.ts index 7da4011..1fcee7f 100644 --- a/libs/runtime-config/src/index.ts +++ b/libs/runtime-config/src/index.ts @@ -65,11 +65,16 @@ interface TenantIntegrations extends ServiceGatewayConfig { configUrl?: string | null; } +interface TenantRuntimeSection { + customer: string; + locale: string; + theme: string; // theme id (device/group-driven theming comes later via XMM) + features: Record; + diagnostics: RuntimeConfig["diagnostics"]; +} + interface TenantConfigFile { - runtime: Omit< - RuntimeConfig, - "layout" | "platform" | "services" | "keymapOverride" | "realtime" | "fonts" - >; + runtime: TenantRuntimeSection; integrations: TenantIntegrations; identity?: unknown; keymap?: KeymapConfig; @@ -115,10 +120,14 @@ export function createRuntimeConfigLoader(options: { // is authoritative and merged on top — so config changes without a rebuild. const merged = await applyRemoteOverride(bundledConfig); const integrations = merged.integrations; + const runtime = merged.runtime; return { - ...merged.runtime, customer, + locale: runtime.locale, + theme: runtime.theme, + features: runtime.features, + diagnostics: runtime.diagnostics, layout: bundledLayout, platform: { ...profile, diff --git a/libs/service-gateway/src/layout.gateway.ts b/libs/service-gateway/src/layout.gateway.ts index 21938ca..160effb 100644 --- a/libs/service-gateway/src/layout.gateway.ts +++ b/libs/service-gateway/src/layout.gateway.ts @@ -10,23 +10,31 @@ export function createLayoutGateway(dependencies: { }): LayoutGateway { return { async getActiveLayout(fallbackLayout) { - if (dependencies.config.layoutSource === "xmm") { - const client = requireClient(dependencies.xmmApi, "XMM"); - const layout = await client.getActiveLayout(dependencies.config.customerId); - return assertCustomerLayout(layout.payload); - } + // Remote layout is an OVERRIDE; the bundled layout is the fallback. A TV + // must boot even when the head-end is unreachable — never let a failed + // fetch crash the app. Any remote error → bundled layout + a warning. + try { + if (dependencies.config.layoutSource === "xmm") { + const client = requireClient(dependencies.xmmApi, "XMM"); + const layout = await client.getActiveLayout(dependencies.config.customerId); + return assertCustomerLayout(layout.payload); + } - if (dependencies.config.layoutSource === "liferay") { - const client = requireClient(dependencies.liferayApi, "Liferay"); - const layout = await client.getLayoutContent("ACTIVE_TV_LAYOUT"); - const payloadField = layout.contentFields.find((field) => field.name === "layoutJson"); - const rawLayout = payloadField?.contentFieldValue.data; + if (dependencies.config.layoutSource === "liferay") { + const client = requireClient(dependencies.liferayApi, "Liferay"); + const layout = await client.getLayoutContent("ACTIVE_TV_LAYOUT"); + const payloadField = layout.contentFields.find((field) => field.name === "layoutJson"); + const rawLayout = payloadField?.contentFieldValue.data; - if (!rawLayout) { - throw new Error("Liferay layout response did not include layoutJson."); - } + if (!rawLayout) { + throw new Error("Liferay layout response did not include layoutJson."); + } - return assertCustomerLayout(JSON.parse(rawLayout) as unknown); + return assertCustomerLayout(JSON.parse(rawLayout) as unknown); + } + } catch (error) { + console.warn("Remote layout fetch failed; using bundled layout.", error); + return fallbackLayout; } return fallbackLayout; diff --git a/libs/themes/src/index.ts b/libs/themes/src/index.ts index 10ccbd8..2a69e95 100644 --- a/libs/themes/src/index.ts +++ b/libs/themes/src/index.ts @@ -1,18 +1,89 @@ +// Carnival brand themes — "moments aboard". The funnel's red/white/blue is the +// constant identity; each theme shifts the environment (light + place). The +// active theme is selected by the tenant config (`runtime.theme`); resolve one +// with getTheme(id). (Device/group-driven theme selection comes later via XMM.) +// +// Brand anchors: Carnival Navy #003C71 · Carnival Red #E4002B · Sun Gold #FFB81C. + export interface ThemeTokens { id: string; colors: { - background: string; - text: string; - accent: string; + background: string; // screen base + surface: string; // rows / cards + text: string; // primary text + textMuted: string; // captions / secondary + accent: string; // brand action / highlight + accentText: string; // text drawn on `accent` + focus: string; // D-pad focus ring (high-contrast on TV) }; } -// CCL (Carnival) brand tokens — matches the "ccl-red" theme in the tenant config. -export const cclTheme: ThemeTokens = { - id: "ccl-red", +// Bright atrium — daytime public spaces (lobby). +export const carnivalDay: ThemeTokens = { + id: "carnival-day", + colors: { + background: "#F2F6FB", + surface: "#FFFFFF", + text: "#003C71", + textMuted: "#5B7A99", + accent: "#E4002B", + accentText: "#FFFFFF", + focus: "#0072CE", + }, +}; + +// Ocean immersive — balcony, horizon in view. +export const carnivalSea: ThemeTokens = { + id: "carnival-sea", + colors: { + background: "#003C71", + surface: "#0A4E86", + text: "#FFFFFF", + textMuted: "#A9C5DD", + accent: "#FFB81C", + accentText: "#003C71", + focus: "#FFB81C", + }, +}; + +// Golden hour — warm dusk variant. +export const carnivalSunset: ThemeTokens = { + id: "carnival-sunset", colors: { - background: "#0a1a2f", - text: "#f5f8fb", - accent: "#e11b3c", + background: "#2A1330", + surface: "#4A2438", + text: "#FFF3E6", + textMuted: "#E4A79C", + accent: "#FF6B4A", + accentText: "#2A1330", + focus: "#FFB81C", }, }; + +// Cabin after dark — low-glare, high-contrast for in-cabin evening comfort. +export const carnivalNight: ThemeTokens = { + id: "carnival-night", + colors: { + background: "#05101E", + surface: "#0E2038", + text: "#F5F8FB", + textMuted: "#8FA6BD", + accent: "#E4002B", + accentText: "#FFFFFF", + focus: "#4FA3E3", + }, +}; + +const registry: Record = { + [carnivalDay.id]: carnivalDay, + [carnivalSea.id]: carnivalSea, + [carnivalSunset.id]: carnivalSunset, + [carnivalNight.id]: carnivalNight, +}; + +export const themeIds = Object.keys(registry); + +// Resolve a theme by id; falls back to the bright day theme if unknown. +export function getTheme(id: string): ThemeTokens { + return registry[id] ?? carnivalDay; +} diff --git a/libs/widgets/src/components/hello-world.component.ts b/libs/widgets/src/components/hello-world.component.ts index 9b1c5fa..3765a4f 100644 --- a/libs/widgets/src/components/hello-world.component.ts +++ b/libs/widgets/src/components/hello-world.component.ts @@ -5,37 +5,40 @@ interface AppState { customer: string; platform: string; locale: string; + theme: string; } -// Minimal Hello World screen. The headline uses the tenant brand font; the -// caption is derived from global reactive state (this.$appState) rather than a -// passed prop — so an on-device deploy confirms the baked-in tenant/platform. +// Hello World screen, themed. Headline in the theme's text color, caption in +// muted, and a brand accent bar — the one bold mark — in the theme accent. +// Colors arrive as props from the resolved theme; caption reads appState so an +// on-device deploy confirms the baked-in tenant/platform/theme. export default Blits.Component("HelloWorld", { template: ` + `, - props: ["background"], + props: { background: {}, text: {}, textMuted: {}, accent: {} }, computed: { caption(): string { const app = (this as unknown as { $appState: AppState }).$appState; - return `${app.customer} · ${app.platform} · ${app.locale}`; + return `${app.customer} / ${app.platform} / ${app.theme}`; }, }, }); diff --git a/libs/widgets/src/components/hero-banner.component.ts b/libs/widgets/src/components/hero-banner.component.ts index f66c84f..b7041ca 100644 --- a/libs/widgets/src/components/hero-banner.component.ts +++ b/libs/widgets/src/components/hero-banner.component.ts @@ -11,5 +11,5 @@ export default Blits.Component("HeroBanner", { `, - props: ["title", "subtitle", "background"], + props: { title: {}, subtitle: {}, background: {} }, }); diff --git a/package.json b/package.json index 3843aed..18235b6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "build:android": "nx build android", "build": "nx run-many -t build -p samsung,lg,android", "build:ccl": "nx run-many -t build -p samsung,lg,android --customer=ccl", + "clean": "rm -rf dist", + "build:clean": "npm run clean && npm run build", "package:samsung": "node tools/packaging/package-tv.mjs --app=samsung", "package:lg": "node tools/packaging/package-tv.mjs --app=lg", "package:android": "node tools/packaging/package-tv.mjs --app=android", diff --git a/tools/executors/build-tv.mjs b/tools/executors/build-tv.mjs index b28d835..d5d908f 100644 --- a/tools/executors/build-tv.mjs +++ b/tools/executors/build-tv.mjs @@ -52,6 +52,7 @@ if (!args.serve) { `--customer=${customer}`, `--profile=${profile}`, ...(args.version ? [`--version=${args.version}`] : []), + ...(args.sssp ? [`--sssp=${args.sssp}`] : []), ], { cwd: process.cwd(), diff --git a/tools/packaging/package-tv.mjs b/tools/packaging/package-tv.mjs index ba26907..dd37755 100644 --- a/tools/packaging/package-tv.mjs +++ b/tools/packaging/package-tv.mjs @@ -11,9 +11,18 @@ const args = parseArgs(process.argv.slice(2)); const app = required(args.app, "app"); const customer = resolveCustomerSlug(args.customer); const profile = args.profile ?? defaultProfileFor(app); -const version = args.version ?? readPackageVersion(); +const tenantConfig = readTenantConfig(customer); +// Release version is engineering-owned + baked into the artifact (gradle-style +// versionName; versionCode derived). It lives in customers//release.json, +// NOT the deployment-generated runtime config.json. Precedence: +// --version (direct CLI) > XTV_APP_VERSION (CI) > release.json > root package.json +const version = + args.version ?? + process.env.XTV_APP_VERSION ?? + readTenantRelease(customer) ?? + readPackageVersion(); const ssspVer = args.sssp ?? "1.00"; -const identity = readTenantIdentity(customer); +const identity = tenantConfig.identity ?? {}; const appMeta = createAppMeta({ app, customer, profile, version, identity }); const webBundleDir = resolve(workspaceRoot, "dist/apps", `${app}-tv`); const platformOutDir = resolve(workspaceRoot, "dist/platforms", app, customer, profile); @@ -122,10 +131,13 @@ async function packageLg({ stageDir, artifactsDir, appMeta }) { if (hasCommand(aresBin)) { execFileSync(aresBin, [stageDir, "-o", artifactsDir], { stdio: "inherit" }); - // Pro:Centric .ipk signing (LG SI tooling) is applied here once signing.signKey - // is provisioned; until then the .ipk is built unsigned. - if (!signing.available) { - warnUnsigned(customer, "lg"); + // An unsigned .ipk is a valid, deployable webOS artifact: it installs on a + // Dev Mode TV via ares-install, and the Content Store signs on submission. + // LG does NOT require a Samsung-style distributor cert. Pro:Centric (SI) + // signing is the exception — applied here only when a sign key is provided. + if (signing.available) { + // TODO: apply Pro:Centric signing with signing.signKey (LG SI tooling). + console.info(`Signing LG .ipk for ${customer} (Pro:Centric).`); } } else { await writeFile( @@ -138,7 +150,6 @@ async function packageLg({ stageDir, artifactsDir, appMeta }) { ].join("\n"), ); console.warn("ares-package not found. LG stage created without .ipk archive."); - warnUnsigned(customer, "lg"); } } @@ -283,19 +294,34 @@ async function copyIconOrPlaceholder(destPath, name) { await writePlaceholderPng(destPath); } -function readTenantIdentity(customer) { +// Parsed tenant config.json (identity, runtime, …); {} if missing/unreadable. +function readTenantConfig(customer) { const path = resolve(workspaceRoot, "customers", customer, "config.json"); if (!existsSync(path)) { return {}; } try { - return JSON.parse(readFileSync(path, "utf8")).identity ?? {}; + return JSON.parse(readFileSync(path, "utf8")); } catch (error) { console.warn(`Could not parse config.json for ${customer}: ${error.message}`); return {}; } } +// Engineering-owned release version from customers//release.json (or undefined). +function readTenantRelease(customer) { + const path = resolve(workspaceRoot, "customers", customer, "release.json"); + if (!existsSync(path)) { + return undefined; + } + try { + return JSON.parse(readFileSync(path, "utf8")).version; + } catch (error) { + console.warn(`Could not parse release.json for ${customer}: ${error.message}`); + return undefined; + } +} + function createAppMeta({ app, customer, profile, version, identity }) { const normalizedCustomer = customer.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-"); const compact = normalizedCustomer.replaceAll("-", "");