diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..cc25e1f --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,19 @@ +# Changesets + +Run `pnpm changeset` in a feature or fix branch, select `lg-luma`, choose a patch, minor or major +bump, and describe the user-facing change. Commit the generated Markdown file with the PR. +Documentation-only and maintenance PRs that do not need a release can omit a changeset. + +PRs receive an updating Changesets status comment. It reports the requested version bump or +notes that no changeset was committed and no version bump is requested. Code or behavior changes +should include a changeset: patch for compatible fixes, minor for compatible features, or major +for breaking changes. The comment is informational and also works on fork PRs. + +Merged changesets accumulate in the automated **Version Packages** PR. Its version command +updates `package.json`, `CHANGELOG.md`, `appinfo.json`, the root `Cargo.toml`, the `luma` entry in +`Cargo.lock`, and the versioned IPK examples in `readme.md`. Supporting Rust crates keep their +own versions. Do not run `pnpm version:release` in ordinary feature PRs. + +The version PR receives the same CI checks as other PRs. Merging it into `main` publishes the +verified IPK and Homebrew feed after CI passes. Other merges and manual CI runs do not publish. +See [the release guide](../development.md#releases) for repository setup and details. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..40f1c63 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [], + "format": "oxfmt", + "privatePackages": { + "version": true, + "tag": false + } +} diff --git a/.changeset/italian-translation.md b/.changeset/italian-translation.md new file mode 100644 index 0000000..7a3f7e1 --- /dev/null +++ b/.changeset/italian-translation.md @@ -0,0 +1,5 @@ +--- +'lg-luma': minor +--- + +Add Italian (`it`) translation: full `messages.json` catalog, plus locale registration in `i18n.rs` (language list, display name, and localized date formatting). diff --git a/.changeset/norwegian-streaming-logos.md b/.changeset/norwegian-streaming-logos.md new file mode 100644 index 0000000..7cc51be --- /dev/null +++ b/.changeset/norwegian-streaming-logos.md @@ -0,0 +1,5 @@ +--- +'lg-luma': minor +--- + +Add curated vector cards for NRK TV and TV 2 Play, with exact app-ID matching, card colours, and desktop demo support. diff --git a/.github/workflows/changeset-status.yml b/.github/workflows/changeset-status.yml new file mode 100644 index 0000000..9214c54 --- /dev/null +++ b/.github/workflows/changeset-status.yml @@ -0,0 +1,58 @@ +name: Changeset PR Status + +on: + pull_request_target: + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + status: + name: Changeset status + if: github.head_ref != 'changeset-release/main' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + comment-body: ${{ steps.status.outputs.comment-body }} + steps: + # Read the PR with the bundled Changesets action, without running PR code or installing dependencies. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + - name: Generate changeset status + id: status + uses: changesets/action/pr-status@v2 + + comment: + name: Update changeset comment + needs: status + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + - name: Create or update the PR comment + uses: changesets/action/pr-comment@v2 + with: + update-id: luma-changeset-status + body: | + ${{ needs.status.outputs.comment-body }} + + --- + + **For code or behavior changes:** run `pnpm changeset`, select `lg-luma`, and commit the generated `.changeset/*.md` file with this PR. + + Choose the semantic version bump that matches the change: + - **patch** for backward-compatible bug fixes. + - **minor** for backward-compatible features. + - **major** for breaking changes. + + A PR without a release changeset does not request an app version bump. Documentation-only or maintenance changes that do not need a release can omit it. + + This comment is informational. Releases are queued in the Version Packages PR and publish only after that PR is merged and CI passes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15f39b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,162 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + release-gate: + name: Identify a merged version PR + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + publish: ${{ steps.gate.outputs.result }} + steps: + - uses: actions/checkout@v7 + - name: Require a merged Changesets PR and a version increase + id: gate + uses: actions/github-script@v8 + with: + script: | + const { isReleaseMerge } = await import(`${process.env.GITHUB_WORKSPACE}/tools/release-gate.mjs`); + return isReleaseMerge({ github, context }); + + build: + name: Rust checks, rendering, and native package + runs-on: ubuntu-24.04 + timeout-minutes: 60 + outputs: + tag: ${{ steps.package.outputs.tag }} + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - uses: dtolnay/rust-toolchain@1.93.0 + with: + components: rustfmt, clippy + targets: armv7-unknown-linux-gnueabi + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + key: webos-${{ hashFiles('tools/build-webos.sh', 'tools/setup-webos-sdk.mjs', 'tools/webos-runtime.mjs') }} + - name: Install desktop and asset dependencies + run: | + apt_options=(-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30) + sudo apt-get "${apt_options[@]}" update + # FontTools recommends large optional scientific Python packages. xauth is needed by xvfb-run. + sudo apt-get "${apt_options[@]}" install -y --no-install-recommends \ + libsdl2-dev pkg-config woff2 python3-fonttools libgl1-mesa-dri xvfb xauth + - name: Check formatting + run: pnpm format:check + - name: Lint Rust and packaging tools + run: pnpm check + - name: Run unit tests + run: pnpm test + - name: Generate native assets + run: pnpm assets + - name: Exercise desktop rendering and mocked weather + env: + LIBGL_ALWAYS_SOFTWARE: '1' + run: xvfb-run -a -s '-screen 0 1920x1080x24' pnpm test:smoke + - name: Set up the webOS SDK + run: pnpm sdk + - name: Cross-build and verify the native IPK and Homebrew feed + id: package + run: | + export WEBOS_NDK="$GITHUB_WORKSPACE/.build/toolchain/arm-webos-linux-gnueabi_sdk-buildroot" + pnpm build + node tools/package.mjs + node tools/repository.mjs + - name: Upload release files + uses: actions/upload-artifact@v7 + with: + name: luma-release + if-no-files-found: error + path: | + dist/*.ipk + dist/sha256sums + dist/repo.json + dist/description.html + dist/app-icon-large.png + - name: Upload native rendering diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: native-rendering + if-no-files-found: ignore + retention-days: 7 + include-hidden-files: true + path: | + .build/*.png + .build/*-render.json + + release: + name: Publish release and Homebrew feed + if: needs.release-gate.outputs.publish == 'true' + needs: [build, release-gate] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: luma-release + path: release + - name: Publish tested release files + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + COMMIT_SHA: ${{ github.sha }} + run: | + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'Invalid release version: %s\n' "$RELEASE_TAG" >&2 + exit 1 + fi + + state="$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \ + --jq ".[] | select(.tag_name == \"$RELEASE_TAG\") | .draft")" + if [ "$state" = false ]; then + printf 'Release %s is already published; skipping.\n' "$RELEASE_TAG" + exit 0 + fi + + existing_tag="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$RELEASE_TAG" \ + --jq ".[] | select(.ref == \"refs/tags/$RELEASE_TAG\") | .ref")" + if [ -n "$existing_tag" ]; then + tagged_commit="$(gh api "repos/$GH_REPO/commits/$RELEASE_TAG" --jq '.sha')" + if [ "$tagged_commit" != "$COMMIT_SHA" ]; then + printf 'Tag %s points to another commit; use a new app version.\n' "$RELEASE_TAG" >&2 + exit 1 + fi + fi + + files=(release/*.ipk release/sha256sums release/repo.json release/description.html release/app-icon-large.png) + if [ "$state" = true ]; then + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber + gh release edit "$RELEASE_TAG" --draft=false + else + gh release create "$RELEASE_TAG" "${files[@]}" \ + --target "$COMMIT_SHA" \ + --generate-notes \ + --title "Luma ${RELEASE_TAG#v}" + fi diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000..855e1aa --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,78 @@ +name: Release PR + +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + version: + name: Maintain Version Packages PR + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + actions: write + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Create or update the version PR + id: changesets + uses: changesets/action@v2 + with: + version-script: pnpm version:release + pr-title: Version Packages + commit-message: Version Packages + create-github-releases: false + push-git-tags: false + - name: Describe the release and run its CI checks + if: steps.changesets.outputs.pr-number != '' + uses: actions/github-script@v8 + env: + RELEASE_PR_NUMBER: ${{ steps.changesets.outputs.pr-number }} + with: + script: | + const pull_number = Number(process.env.RELEASE_PR_NUMBER); + const { data: pull } = await github.rest.pulls.get({ ...context.repo, pull_number }); + const { data: file } = await github.rest.repos.getContent({ + ...context.repo, + path: 'package.json', + ref: pull.head.sha, + }); + const { version } = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')); + await github.rest.pulls.update({ + ...context.repo, + pull_number, + body: [ + '## Problem', + '', + 'Merged changes are waiting for a coordinated app release.', + '', + '## Solution', + '', + `Release Luma **${version}** with the accumulated Changesets changelog.`, + '', + 'Synchronize package.json, appinfo.json, the root Cargo.toml, Cargo.lock and the README IPK examples.', + 'Merging this PR into main publishes the verified IPK and Homebrew feed after CI passes.', + ].join('\n'), + }); + // GITHUB_TOKEN-created PRs do not trigger pull_request workflows. + // workflow_dispatch is explicitly allowed and checks the version branch's head commit. + await github.rest.actions.createWorkflowDispatch({ + ...context.repo, + workflow_id: 'ci.yml', + ref: pull.head.ref, + }); diff --git a/.gitignore b/.gitignore index 849ddff..b101c45 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ +node_modules/ dist/ +.build/ +.DS_Store +*.key +.env +.env.* +target/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..e20b102 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all", + "sortPackageJson": false, + "overrides": [{ "files": ["tv/*.cjs"], "options": { "trailingComma": "none" } }], + "ignorePatterns": [ + "node_modules/**", + "dist/**", + ".build/**", + "target/**", + "pnpm-lock.yaml", + "assets/fonts/**", + "assets/licenses/**", + "assets/ui-icons/**" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..faaf790 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["oxc"], + "categories": { + "correctness": "error" + }, + "rules": { + "eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + }, + "ignorePatterns": ["node_modules/**", "dist/**", ".build/**", "target/**"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..72f2b3f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# lg-luma + +## 2.3.1 + +### Patch Changes + +- db4fece: Fix Home takeover enable failing with "no app matched by pid" by closing the normal Luma instance through the app-ID endpoint. Allow takeover to continue if that instance already exited during the close request. +- db4fece: Update the normal app launch splash to match the saved background, including after changing it in Settings. Refresh the matching Home takeover splash as well. + +## 2.3.0 + +### Minor Changes + +- 3bf0ad6: Show a wallpaper splash at launch and match the saved background when staging the native Home integration. + +## 2.2.2 + +### Patch Changes + +- 072b7e9: Show installed app icons as soon as they are decoded instead of one per idle wake-up. +- fed795c: Keep decoded app icons across the automatic inventory refreshes at startup and whenever Luma becomes visible, reading an icon again only when its app's version or icon paths change. Refresh apps still reloads every icon. +- 969a6fd: Keep the neutral card colour for transparent app icons whose logo reaches the edges, instead of filling the card with the logo's own colour. + +## 2.2.1 + +### Patch Changes + +- d81cb66: Publish the changes from the unreleased 2.2.0 version after improving CI dependency installation and build timeouts. + +## 2.2.0 + +### Minor Changes + +- 201c030: Add an optional seconds display to the header clock, configurable with the remote in Clock format settings. +- e8f9085: Add 27 curated vector logos for streaming, music, and regional TV apps, including NOW / Now TV, plus dedicated Live TV and browser vector cards. Select branded cards by exact app IDs instead of keywords, so unrelated apps such as Amazon Alexa retain their installed artwork. Include every SVG-backed app in the desktop demo and add a rendered gallery preview. + + Remove dumped TV artwork, icon/colour maps, and their collection tools and tests. Desktop previews now use curated vectors or generic/category fallbacks, and builds no longer depend on collected icon fixtures. + +- 589044b: Add custom input names: rename any input with the remote in Settings → Inputs, or reset it to the + port name. Home cards, settings rows and the breadcrumb show the custom name, keeping the TV label, + port and connection status as detail. +- 978b946: Discover app icons directly from installed TV applications instead of requiring collected artwork and icon-map updates. Load visible artwork in the background, derive matching card colours automatically, and refresh cached icons with app inventory. Exclude desktop-only collected icons and maps from TV packages. +- 9463b56: Add an optional name to the Home greeting, with remote-friendly name replacement and reset controls. +- e2c2a12: Add separate Home and Apps caption settings, with names hidden on Home and visible in Apps by default. + Legacy caption settings do not override the new per-view defaults. + Group greeting name editing under Greeting, use Clear name for greeting and input names, and reserve + the bright settings-row highlight for the focused control. + + Group clock preferences under Clock, with separate nested Clock format and Time format selections. + Time format offers HH:MM by default or HH:MM:SS while retaining existing saved seconds preferences. + Move Refresh apps & inputs and Quit app into a dedicated System sidebar category. + Keep the top-level Clock row free of format values and remove the chevron from input Rename actions. + +### Patch Changes + +- dcf1b03: Read the real HDMI cable state from `hdmiPlugIn` on TVs that report every port as `connected`, so + inputs in Auto mode only appear when a device is actually plugged in. + +## 2.1.0 + +### Minor Changes + +- d417291: Add Brazilian Portuguese as a selectable interface language, including translated app labels, + localized dates and weather location searches. + +### Patch Changes + +- 27802ac: Match brand keywords at ASCII word boundaries so unrelated apps such as dashboards retain their + own artwork and Utilities category instead of being mistaken for ARD/Das Erste. +- 14b18f8: Render Korean app and input names using bundled Hangul font subsets loaded on demand. Add + Korean-labelled LG Gallery and Room Connect apps to the desktop preview inventory. +- 3e256f6: Add installed-app artwork collected from an LG C1 for Korean-market apps, with matching card + background colours. Make icon collection work on older webOS Node versions and follow the + launcher's app visibility and exclusion rules. + + Include Coupang Play, Watcha Play, Wavve, Rainbow TV and LG TV VOD in the desktop demo inventory + so their artwork can be previewed during development. + +- ee2f6ff: Coordinate app releases through Changesets with synchronized Rust and webOS versions, publishing + only after the checked Version Packages PR is merged. + + Add updating PR comments that report missing changesets and explain semantic version bumps for + code changes. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..421a135 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,934 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "luma" +version = "2.3.1" +dependencies = [ + "gl", + "image", + "libc", + "luma-vector-geometry", + "owned_ttf_parser", + "sdl2", + "serde", + "serde_json", + "time", + "ureq", +] + +[[package]] +name = "luma-vector-assets" +version = "0.1.0" +dependencies = [ + "luma-vector-geometry", + "serde", + "serde_json", + "usvg", +] + +[[package]] +name = "luma-vector-geometry" +version = "0.1.0" +dependencies = [ + "lyon_tessellation", +] + +[[package]] +name = "lyon_geom" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd4872650e70dc907dd4f7ebc449d7d657a0d2658e423b2e724970870bbbf75b" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "sdl2" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "sdl2-sys", +] + +[[package]] +name = "sdl2-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" +dependencies = [ + "cfg-if", + "libc", + "pkg-config", + "version-compare", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2b90848 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "luma" +version = "2.3.1" +edition = "2024" +rust-version = "1.88" +publish = false +default-run = "luma" +license = "MIT" +description = "A remote-first native launcher for rooted LG webOS TVs" + +[workspace] +members = ["vector-geometry", "tools/vector-assets"] +resolver = "3" + +[features] +default = [] +webos = ["dep:libc"] + +[dependencies] +gl = "0.14" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +libc = { version = "0.2", optional = true } +luma-vector-geometry = { path = "vector-geometry" } +owned_ttf_parser = "0.25" +sdl2 = { version = "0.38", features = ["use-pkgconfig"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = { version = "0.3", features = ["local-offset", "formatting", "parsing"] } +ureq = { version = "3.4", default-features = false, features = ["rustls"] } + +[profile.release] +lto = "thin" +strip = true +codegen-units = 1 + +# Keep on-demand glyph geometry responsive in desktop debug previews. +[profile.dev.package.lyon_tessellation] +opt-level = 3 + +[profile.dev.package.ttf-parser] +opt-level = 3 diff --git a/README.md b/README.md index c6716ed..47d1caa 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,40 @@ -# luma-home +# Luma Home -Replaces the LG webOS Home screen with [Luma](https://github.com/ecklf/lg-luma) on webOS 5/6 TVs rooted via [Homebrew Channel](https://github.com/nicko88/LG-Homebrew). +Fork of [Luma](https://github.com/ecklf/lg-luma) — a native launcher for rooted LG webOS TVs — with a Home Assistant integration tab and webOS 5/6 Home button support. -## How it works +## What's different from upstream -LG Home (`com.webos.app.home`) is a native QML app. This project bind-mounts a transparent web page over its directory so SAM sees a web-type Home. The web page immediately calls `WebOSServiceBridge` to launch Luma. A background watcher provides a fallback redirect via `luna-send` subscription. +- **Home Assistant tab** — a fourth nav tab ("Home") with glass cards for controlling HA entities (lights, climate, scenes, vacuum, etc.) directly from the TV remote +- **webOS 5/6 Home trampoline** — bind-mount overlay that redirects the Home button to Luma on older webOS versions (see `trampoline/`) +- **Settings app theme** — transparent overlay for the webOS Settings app (see `settings-theme/`) -### Components +## Structure -| File | On TV at | Purpose | -|------|----------|---------| -| `overlay/appinfo.json` | `/media/developer/luma-home/overlay/` | Home manifest — web type, transparent, no splash | -| `overlay/index.html` | same | Minimal page that launches Luma via WebOSServiceBridge | -| `boot-hook.sh` | `/media/developer/luma-home/` | Homebrew Channel boot hook — mounts overlay, restarts SAM, launches Luma | -| `watcher.sh` | same | Background watcher — subscribes to foreground events, redirects Home→Luma | +| Directory | What | +|-----------|------| +| `src/` | Rust source (SDL2 + OpenGL ES 2 renderer) | +| `assets/` | Generated artwork, fonts, wallpapers | +| `ha-home/` | HA tab config schema and default config | +| `trampoline/` | webOS 5/6 Home button redirect (boot hook, watcher, overlay) | +| `settings-theme/` | Transparent Settings app theme | -### Boot flow +## Building -1. Homebrew Channel runs `boot-hook.sh` at cold boot -2. Bind-mount replaces `/usr/palm/applications/com.webos.app.home` (RAM only — reboot without the hook restores stock) -3. SAM restarts and reads the web manifest -4. Luma launches as the initial app -5. Watcher starts in the background - -### Home button flow - -1. User presses Home → system opens `com.webos.app.home` (our transparent web page) -2. `index.html` JS fires `WebOSServiceBridge` → launches Luma -3. Watcher catches the foreground change as backup → also launches Luma - -## Requirements - -- LG webOS TV rooted with Homebrew Channel (tested on webOS 6.5.3) -- Luma v2.1.0+ installed as `com.ecklf.luma` -- Telnet or SSH access to the TV - -## Install +See `development.md` for the full build guide. Quick summary: ```sh -# Copy files to TV -scp -r overlay/ boot-hook.sh watcher.sh root@:/media/developer/luma-home/ - -# SSH into TV and install the boot hook -chmod +x /media/developer/luma-home/boot-hook.sh /media/developer/luma-home/watcher.sh -ln -sf /media/developer/luma-home/boot-hook.sh /var/lib/webosbrew/init.d/10-luma-home - -# Run it now (or reboot) -sh /media/developer/luma-home/boot-hook.sh +nix develop # enter the dev shell with cross-compilation toolchain +pnpm install # install JS tooling for asset generation +pnpm assets # generate native artwork +cargo build --release --features webos --target armv7-unknown-linux-gnueabi ``` -## Uninstall +## Target TV -```sh -# Remove boot hook -rm /var/lib/webosbrew/init.d/10-luma-home +- LG 65NANO813PA, webOS 6.5.3-47 +- Rooted via Homebrew Channel v0.7.3 +- Luma installed as `com.ecklf.luma` -# Kill watcher -kill $(cat /tmp/luma-watcher.pid 2>/dev/null) +## License -# Unmount overlay (or just reboot) -umount /usr/palm/applications/com.webos.app.home -``` - -## Known limitations - -- Brief visual transition when pressing Home (web page loads before Luma appears) -- `blockSystemApp` API (used by Luma's built-in Home integration for webOS 25/26) does not exist on webOS 5/6, so we use `systemctl restart sam` instead -- Native Home replacement (Luma binary as Home directly) does not work on webOS 6 — SAM handles native system apps differently - -## Future - -Fork Luma and patch `home.cjs` to support webOS 5/6 natively — replace the version check and `blockSystemApp` calls with the `systemctl restart sam` approach proven here. - -## Tested on - -- LG 65NANO813PA, webOS 6.5.3-47 (kisscurl-koli) -- Luma v2.1.0 -- Homebrew Channel v0.7.3 +Upstream Luma is MIT licensed. See `license` file. diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..8ad201b --- /dev/null +++ b/agents.md @@ -0,0 +1,99 @@ +# Luma agent context + +## Project + +Luma is a native Rust/SDL2/OpenGL ES 2 launcher for rooted LG webOS TVs. Rust source is in +`src/`, original assets/translations in `assets/`, shared vector geometry in `vector-geometry/`, +the host SVG compiler in `tools/vector-assets/`, packaging/tooling in `tools/`, and desktop smoke +scripts in `tests/`. There is no React/JSX/browser application. Read `development.md` for details. + +Use pnpm, Oxlint and Oxfmt for build tools; Cargo/rustfmt/Clippy for Rust. `nix develop` supplies +pinned Rust 1.93, ARMv7 std, Node 24, pnpm 10.34.5, SDL2, woff2 and Python/fontTools. The webOS +SDK is separate: `pnpm sdk`, then set `WEBOS_NDK` to `.build/toolchain/arm-webos-linux-gnueabi_sdk-buildroot`. + +```sh +pnpm install --frozen-lockfile +pnpm format:check +pnpm check +pnpm test +pnpm assets +pnpm test:smoke +pnpm run package +``` + +`pnpm check` does not execute tests. `pnpm test` is workspace unit tests; smoke scripts render +desktop OpenGL and mock weather HTTP. Linux can use `xvfb-run -a pnpm test:smoke`. Tests must not +contact real weather APIs or a TV. Respect any user instruction to use manual TV verification +instead of running automated tests. Builds/lint may still be run. Do not claim hardware behavior +from desktop checks. Add a `pnpm changeset` file for release-worthy changes and leave version +bumps to the automated Version Packages PR. Its `pnpm version:release` command synchronizes +`package.json`, `appinfo.json`, the root `Cargo.toml`, the `luma` entry in `Cargo.lock` and README +IPK examples. Supporting Rust crates retain independent versions. `pnpm version:check` checks +consistency without writing files. Code or behavior changes should use semantic versioning: +patch for backward-compatible fixes, minor for backward-compatible features, and major for +breaking changes. Documentation-only or maintenance changes that do not need a release can omit +a changeset. + +## Build and identity + +`com.ecklf.luma` is the native app's ID and `luma` its executable. Root `appinfo.json` is the +package metadata source. It installs separately from the former web app `com.luma.launcher` and +prototypes `com.luma.launcher.native` and `com.luma.native`, all excluded from app inventory. Preferences are local JSON +per installation; browser local storage and preferences from other IDs are not migrated. + +ARM builds must use the NDK's gnueabi soft-float calling convention and explicit sysroot, with +isolated pkg-config. Preserve the Cortex-A9/VFP Rust flags and `getauxval` compatibility. +The SDK's SDL has dummy video: use checksum-pinned SDL-webOS with standard SDL ABI and a real +webOS/Wayland backend. Do not use rust-sdl2's generic bundled SDL or ship host libraries. Native +packages use `Architecture: arm`, preserve executable mode and include licenses/checksums/feed. + +## Runtime constraints + +Keep input/rendering on the SDL thread and TV/HTTP work off it. Luna requests originate from the +native process, not `luna-send-pub -a` impersonation. Inventory failure must never substitute demo +data on TV. Explicit `--test-extra-apps` is a labeled manual preview applied only after successful +inventory reads, with separate preferences and no placeholder launch requests. + +Pointer hover/down must not move a clicked control. Home favorites use one horizontal strip; +remote focus/reordering reveals the whole selected card. Apps Up/Down follows six-column grid +indices, including offscreen rows, so headers cannot steal mid-grid navigation. Back restores +setting subpage parents and supports SDL-webOS Back. OK finishes move mode without launching. + +Vector icon/logo assets are standalone SVGs compiled to `.lvec` at build time. Font outlines +are tessellated only as needed; text runs use bounded GPU caches, lazy CJK and no glyph bitmap +atlas. Text AA prefers MSAA of intact outlines, with a narrow fringe fallback; keep physical +origin/baseline snapping and logical 1920×1080 layout. `--resolution 4k` is experimental, not a +shipping-default claim. Profiles count presentations; ordinary `--frames` is event-loop iterations. + +Weather uses ureq/rustls, fixed Open-Meteo origins, bounded replies/timeouts, no proxy/redirects, +separate workers/cache and generation IDs. Cache Celsius and convert units without replacing it. +Only configured/enabled/visible Home starts forecasts. Shared translations are bundled locally. +Match the current native design without on-screen weather credits; attribution remains in docs. + +## Assets + +SVG sources and fonts retain licenses in `assets/`. TV app artwork is resolved at runtime from +inventory/installed manifests by `src/app_artwork.rs`, with an embedded Node 8-compatible elevated +reader for jailed installations. Reads/decoding stay on its worker; GPU uploads stay on SDL. +Refresh invalidates cached results. No collected icon map or card-colour JSON is used or shipped. +Desktop previews use curated vectors and generic/category fallbacks. Do not add dumped TV images +or per-TV icon maps to the repository. +Do not replace vector logos with PNG exports. +Curated vectors use the exact app-ID allowlist in `src/branding.rs`, never title/substring matching. +Every logo in `assets/logos/` must have a desktop demo app. `pnpm test:brands` renders the full +gallery to `.build/brands-gallery.png`; source restoration via `pnpm assets:brands` is optional. + +## CI and releases + +`.github/workflows/ci.yml` is native-only: format/lint/workspace tests, asset generation and +mocked desktop rendering, SDK setup, ARM cross-build, actual IPK verification/checksum/feed, +artifacts and guarded publication. Keep existing published-version/draft/tag-commit checks. +`.github/workflows/changeset-status.yml` maintains an informational changeset status comment on +PRs, including forks, with semantic-version guidance for code changes. It skips the generated +version PR. Its `pull_request_target` jobs must not install dependencies or run code from the PR. +`.github/workflows/release-pr.yml` maintains the Changesets version PR and dispatches its CI +checks with the built-in token. Only a merged `changeset-release/main` PR with a version increase +can publish after the main-branch CI build passes. Ordinary PRs, other merges and manual CI runs +still build/check/package but do not publish. See `development.md#releases` for setup and retries. +Only create commits or PRs when explicitly requested. For PR descriptions follow the global +`commands/pr.md` format instructions. diff --git a/appinfo.json b/appinfo.json new file mode 100644 index 0000000..d08b2ac --- /dev/null +++ b/appinfo.json @@ -0,0 +1,18 @@ +{ + "id": "com.ecklf.luma", + "version": "2.3.1", + "vendor": "Luma", + "type": "native", + "main": "luma", + "title": "Luma", + "icon": "app-icon.png", + "largeIcon": "app-icon-large.png", + "appDescription": "A remote-first native webOS launcher.", + "resolution": "1920x1080", + "appCategory": ["home"], + "requiredACG": ["application.launcher", "application.query", "lunabus.query"], + "handlesRelaunch": false, + "iconColor": "#25272d", + "splashBackground": "splash.png", + "bgColor": "#17191c" +} diff --git a/assets.md b/assets.md new file mode 100644 index 0000000..e107300 --- /dev/null +++ b/assets.md @@ -0,0 +1,116 @@ +# Luma assets and references + +- `assets/mark.svg`, `assets/app-icon.svg`, and the Midnight/Slate/Prism SVG wallpapers: + original Luma artwork, MIT. webOS supplies the app icon panel using `iconColor`. +- `assets/wallpapers/dunes.jpg`: Unsplash photo `1509316785289-025f5b846b35`, + https://images.unsplash.com/photo-1509316785289-025f5b846b35, [Unsplash License](https://unsplash.com/license). +- `assets/wallpapers/san-francisco.jpg`: Unsplash photo `1501594907352-04cda38ebc29`, + https://images.unsplash.com/photo-1501594907352-04cda38ebc29, Unsplash License. +- `assets/wallpapers/summit.jpg`: Unsplash photo `1454496522488-7a8e488e8606`, + https://images.unsplash.com/photo-1454496522488-7a8e488e8606, Unsplash License. +- `assets/wallpapers/forest.jpg`: Unsplash photo `1441974231531-c6227db76b6e`, + https://images.unsplash.com/photo-1441974231531-c6227db76b6e, Unsplash License. +- `assets/wallpapers/yosemite.jpg`: [Tunnel View, Yosemite Valley, Yosemite NP](https://commons.wikimedia.org/wiki/File:Tunnel_View,_Yosemite_Valley,_Yosemite_NP_-_Diliff.jpg) + by David Iliff (Diliff), [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/). + Resized to a 2560px JPEG; generated wallpapers/thumbnails retain the source license. +- `assets/wallpapers/northern-lights.jpg`: Unsplash photo `1531366936337-7c912a4589a7`, + https://images.unsplash.com/photo-1531366936337-7c912a4589a7, Unsplash License. +- Inter variable font: Rasmus Andersson, SIL Open Font License 1.1, `assets/fonts/ofl.txt`. +- Noto Sans SC: copyright 2014–2021 Adobe, SIL OFL 1.1. Source/license in `assets/fonts/`; + `tools/font-assets.mjs` pins the Google Fonts revision and checksums. Generated regular/medium + subsets are renamed Luma CJK and retain copyright/license tables. +- Noto Sans KR: copyright 2014–2021 Adobe, SIL OFL 1.1 (same license text as Noto Sans SC, + `assets/fonts/noto-ofl.txt`). Pinned the same way. Google Fonts ships Hangul only in this family, + not in Noto Sans SC; generated Hangul-only regular/medium subsets are renamed Luma KR. +- Lucide interface icons: exported standalone SVG sources in `assets/ui-icons/`, ISC license + bundled at `assets/licenses/lucide.txt`. PlayStation/Apple TV input paths were supplied by the + user; the brand marks belong to Sony/Apple. Spotify/Kodi symbol treatments use the original + native artwork. Logos identify third-party apps; Luma is unaffiliated with their owners. +- SDL-webOS 2.30.12 (`release-2.30.12-webos.5`, standard SDL ABI), zlib license. + `tools/webos-runtime.mjs` records archive/library SHA-256. Notice at `assets/licenses/sdl2.txt`. + +The bundled icon/logo SVG sources are compiled into derived `.lvec` triangles with the same +source licenses. Apple TV, Disney+, Prime Video and Das Erste retain the white display treatment. +YouTube's wordmark is white and its play mark red. Jellyfin retains its gradient and in-file +CC BY-SA attribution. Source licence notices and this document travel inside the IPK. + +Generated `.build/assets/thumbnails/` are cropped/resized derivatives of the same wallpaper +sources. Original sources remain in `assets/`; no artwork service is contacted on the TV. + +## Brand SVGs + +| Asset | Source | License / attribution | +| ----------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `apple-tv.svg` | [Apple TV](https://commons.wikimedia.org/wiki/File:Apple_TV_logo.svg) | Apple Inc.; public-domain logo artwork | +| `prime-video.svg` | [Prime Video](https://commons.wikimedia.org/wiki/File:Amazon_Prime_Video_logo.svg) | Amazon; public-domain logo artwork | +| `disney-plus.svg` | [Disney+](https://commons.wikimedia.org/wiki/File:Disney%2B_logo.svg) | Disney Enterprises; public-domain logo artwork | +| `jellyfin.svg` | [Jellyfin branding](https://github.com/jellyfin/jellyfin-ux/blob/master/branding/SVG/banner-dark.svg) | Jellyfin contributors; [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | +| `netflix.svg` | [Netflix](https://commons.wikimedia.org/wiki/File:Netflix_2015_logo.svg) | Netflix Inc.; public-domain logo artwork | +| `youtube.svg` | [YouTube](https://commons.wikimedia.org/wiki/File:YouTube_2024.svg) | Google Inc.; public-domain logo artwork | +| `arte.svg` | [ARTE](https://commons.wikimedia.org/wiki/File:Arte_Logo_2026.svg) | ARTE; public-domain logo artwork | +| `zdf.svg` | [ZDF](https://commons.wikimedia.org/wiki/File:ZDF_logo.svg) | ZDF; public-domain logo artwork | +| `das-erste.svg` | [Das Erste](https://commons.wikimedia.org/wiki/File:Das_Erste_2014.svg) | ARD; public-domain logo artwork | + +### Expanded streaming and music artwork + +`tools/brand-assets.mjs` records each upstream URL and SHA-256. The SVGs are checked in, so +ordinary builds and TV rendering remain offline. `pnpm assets:brands` is an optional source +restoration command. The mesh compiler tightly fits these logos and applies white/dark-card ink +treatments while retaining selected brand colours. Peacock and Watcha have redundant rectangular +clips removed. Coupang Play's static SVG paths are extracted from its source component. + +| SVG asset | Source / attribution | Licence | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `spotify.svg` | [Spotify](https://commons.wikimedia.org/wiki/File:Spotify_logo_with_text.svg) | Public domain logo | +| `plex.svg` | [Plex via Simple Icons](https://simpleicons.org/?q=plex) | CC0 contribution | +| `hbo-max.svg` | [HBO Max 2025](), Warner Bros. Discovery, LucasJance2006 and Lee6597 | Public domain logo | +| `paramount-plus.svg` | [Paramount+ via Simple Icons](https://simpleicons.org/?q=paramount) | CC0 contribution | +| `peacock.svg` | [Peacock 2026](), NBCUniversal | Public domain logo | +| `hulu.svg` | [Hulu]() | Public domain logo | +| `crunchyroll.svg` | [Crunchyroll via Simple Icons](https://simpleicons.org/?q=crunchyroll) | CC0 contribution | +| `twitch.svg` | [Twitch via Simple Icons](https://simpleicons.org/?q=twitch) | CC0 contribution | +| `dazn.svg` | [DAZN via Simple Icons](https://simpleicons.org/?q=dazn) | CC0 contribution | +| `rakuten-tv.svg` | [Rakuten TV](https://commons.wikimedia.org/wiki/File:Rakuten_TV_logo.svg), Kashiwa Satō | Public domain logo | +| `apple-music.svg` | [Apple Music 2026 wordmark](), Apple Inc. | Public domain logo | +| `amazon-music.svg` | [Amazon Music](https://commons.wikimedia.org/wiki/File:Amazon_Music_logo.svg), Amazon.com LLC | Public domain logo | +| `bbc-iplayer.svg` | [BBC iPlayer](), BBC | Public domain logo | +| `itvx.svg` | [ITVX via Simple Icons](https://simpleicons.org/?q=itvx) | CC0 contribution | +| `channel-4.svg` | [Channel 4 via Simple Icons](https://simpleicons.org/?q=channel4) | CC0 contribution | +| `now-tv.svg` | [NOW / Now TV via Simple Icons](https://simpleicons.org/?q=now), Sky | CC0 contribution | +| `rtl-plus.svg` | [RTL+](https://commons.wikimedia.org/wiki/File:RTL%2B_Logo_2021.svg), RTL Group | Public domain logo | +| `joyn.svg` | [Joyn](https://commons.wikimedia.org/wiki/File:Joyn_logo.svg), Joyn GmbH, vector by Daniel FR | Public domain logo | +| `waipu-tv.svg` | [waipu.tv](https://commons.wikimedia.org/wiki/File:Waipu_TV_Logo_2016.svg), Exaring AG | Public domain logo | +| `zattoo.svg` | [Zattoo](https://commons.wikimedia.org/wiki/File:Logo_Zattoo.svg) | Public domain logo | +| `skyshowtime.svg` | [SkyShowtime](https://commons.wikimedia.org/wiki/File:SkyShowtime_Logo.svg), Comcast / Paramount | Public domain logo | +| `coupang-play.svg` | Coupang Play, [SVG source preserved by SinJungho](https://github.com/SinJungho/Damoa_Team-Project/blob/db1955f0f6f09d9329518120afe42af375632de0/src/svg/CoupangPlayLogo.jsx) | Coupang brand artwork, used to identify the app | +| `tving.svg` | [TVING](https://commons.wikimedia.org/wiki/File:TVING_logo.svg) | Public domain logo | +| `wavve.svg` | [Official Wavve artwork](https://www.wavve.com/static/element-icons-tag-ic-brand-wavve-b343dcb2.svg) | Wavve brand artwork, used to identify the app | +| `watcha.svg` | [Watcha](https://commons.wikimedia.org/wiki/File:Watcha_logo.svg), xixi pictures | Public domain logo | +| `movistar-plus.svg` | [Movistar Plus+](https://commons.wikimedia.org/wiki/File:Movistar_Plus%2B_2022_logo.svg) | Public domain logo | +| `filmin.svg` | Filmin, [SVG source preserved by DCueto](https://github.com/DCueto/filmin-svelte-clone/blob/36c977e31ec76abb42473b243584acbaed23579f/src/assets/filmin_logo.svg), originally Brandlogos.net | Filmin brand artwork, used to identify the app | +| `nrk-tv.svg` | [Official NRK TV wordmark](https://tv.nrk.no) | NRK brand artwork, used to identify the app | +| `tv2-play.svg` | [Official TV 2 Play logo](https://play.tv2.no) | TV 2 brand artwork, used to identify the app | + +Simple Icons contributions use [CC0](https://creativecommons.org/publicdomain/zero/1.0/), with +the notice bundled in `assets/licenses/simple-icons.txt`. Trademark and other underlying brand +rights remain with the respective owners. The source artwork's attribution and terms also apply +to compiled `.lvec` derivatives. + +### Live TV and browser cards + +`live-tv.svg` and `browser.svg` are original Luma artwork, MIT. Both share a blue/cyan material +with a light rim and white details. Live TV uses a rounded display, play symbol and live +indicator. Browser uses a globe with a navigation pointer. The gradients and highlights are vector +geometry, with no embedded images, runtime fonts, filters or raster effects. Each utility card +uses its native app's exact ID. + +On TV, installed-app PNG/JPEG/WebP artwork is read from that TV's installed applications at runtime. +Desktop previews use curated vector logos or generic/category fallbacks, with no dumped TV artwork +or collected icon maps in the repository. Names and marks remain the property of the respective +app owners. Luma's folded-light L logo is original artwork; its SVG geometry produces the +small/large webOS installation icons. + +Weather data: [Open-Meteo](https://open-meteo.com/), [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). + +The provided Apple design guidance informed focus, motion and materials. This is not an Apple +or LG product. diff --git a/assets/app-icon.svg b/assets/app-icon.svg new file mode 100644 index 0000000..e25511e --- /dev/null +++ b/assets/app-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/fonts/inter-latin.woff2 b/assets/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/assets/fonts/inter-latin.woff2 differ diff --git a/assets/fonts/noto-ofl.txt b/assets/fonts/noto-ofl.txt new file mode 100644 index 0000000..1c9f432 --- /dev/null +++ b/assets/fonts/noto-ofl.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/noto-sans-kr.ttf b/assets/fonts/noto-sans-kr.ttf new file mode 100644 index 0000000..b386890 Binary files /dev/null and b/assets/fonts/noto-sans-kr.ttf differ diff --git a/assets/fonts/noto-sans-sc.ttf b/assets/fonts/noto-sans-sc.ttf new file mode 100644 index 0000000..fb0637b Binary files /dev/null and b/assets/fonts/noto-sans-sc.ttf differ diff --git a/assets/fonts/ofl.txt b/assets/fonts/ofl.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/ofl.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/homebrew-description.html b/assets/homebrew-description.html new file mode 100644 index 0000000..9014309 --- /dev/null +++ b/assets/homebrew-description.html @@ -0,0 +1,7 @@ +

A native launcher for rooted LG webOS TVs, inspired by Apple TV.

+

+ Launch apps and inputs, organize your favorites, and browse your app library with remote or Magic + Remote pointer controls. Customize backgrounds and input labels, and add weather for a city of + your choice. English, German, Spanish and Simplified Chinese are included. +

+

Requires a rooted TV and elevated Homebrew Channel.

diff --git a/assets/licenses/lucide.txt b/assets/licenses/lucide.txt new file mode 100644 index 0000000..325e8ff --- /dev/null +++ b/assets/licenses/lucide.txt @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/assets/licenses/sdl2.txt b/assets/licenses/sdl2.txt new file mode 100644 index 0000000..52d0ed3 --- /dev/null +++ b/assets/licenses/sdl2.txt @@ -0,0 +1,17 @@ +Copyright (C) 1997-2025 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. diff --git a/assets/licenses/simple-icons.txt b/assets/licenses/simple-icons.txt new file mode 100644 index 0000000..70d4a7b --- /dev/null +++ b/assets/licenses/simple-icons.txt @@ -0,0 +1,30 @@ +# CC0 1.0 Universal + +## Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose. + +4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +For more information, please see . diff --git a/assets/logos/amazon-music.svg b/assets/logos/amazon-music.svg new file mode 100644 index 0000000..781dc6e --- /dev/null +++ b/assets/logos/amazon-music.svg @@ -0,0 +1,377 @@ + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/apple-music.svg b/assets/logos/apple-music.svg new file mode 100644 index 0000000..9cfa42e --- /dev/null +++ b/assets/logos/apple-music.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/logos/apple-tv.svg b/assets/logos/apple-tv.svg new file mode 100644 index 0000000..ebe0487 --- /dev/null +++ b/assets/logos/apple-tv.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/logos/arte.svg b/assets/logos/arte.svg new file mode 100644 index 0000000..79ac101 --- /dev/null +++ b/assets/logos/arte.svg @@ -0,0 +1,63 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/logos/bbc-iplayer.svg b/assets/logos/bbc-iplayer.svg new file mode 100644 index 0000000..54dfb33 --- /dev/null +++ b/assets/logos/bbc-iplayer.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/logos/browser.svg b/assets/logos/browser.svg new file mode 100644 index 0000000..a970ce4 --- /dev/null +++ b/assets/logos/browser.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/channel-4.svg b/assets/logos/channel-4.svg new file mode 100644 index 0000000..8fa7552 --- /dev/null +++ b/assets/logos/channel-4.svg @@ -0,0 +1,2 @@ + +Channel 4 diff --git a/assets/logos/coupang-play.svg b/assets/logos/coupang-play.svg new file mode 100644 index 0000000..f9365db --- /dev/null +++ b/assets/logos/coupang-play.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + diff --git a/assets/logos/crunchyroll.svg b/assets/logos/crunchyroll.svg new file mode 100644 index 0000000..d6d96f1 --- /dev/null +++ b/assets/logos/crunchyroll.svg @@ -0,0 +1,2 @@ + +Crunchyroll diff --git a/assets/logos/das-erste.svg b/assets/logos/das-erste.svg new file mode 100644 index 0000000..bb6292e --- /dev/null +++ b/assets/logos/das-erste.svg @@ -0,0 +1,16 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/logos/dazn.svg b/assets/logos/dazn.svg new file mode 100644 index 0000000..4507dcd --- /dev/null +++ b/assets/logos/dazn.svg @@ -0,0 +1,2 @@ + +DAZN diff --git a/assets/logos/disney-plus.svg b/assets/logos/disney-plus.svg new file mode 100644 index 0000000..7b37a8d --- /dev/null +++ b/assets/logos/disney-plus.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/filmin.svg b/assets/logos/filmin.svg new file mode 100644 index 0000000..5522951 --- /dev/null +++ b/assets/logos/filmin.svg @@ -0,0 +1,2 @@ + +Filmin logo - Brandlogos.net diff --git a/assets/logos/hbo-max.svg b/assets/logos/hbo-max.svg new file mode 100644 index 0000000..c70558f --- /dev/null +++ b/assets/logos/hbo-max.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/logos/hulu.svg b/assets/logos/hulu.svg new file mode 100644 index 0000000..42ae9d4 --- /dev/null +++ b/assets/logos/hulu.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/logos/itvx.svg b/assets/logos/itvx.svg new file mode 100644 index 0000000..2388693 --- /dev/null +++ b/assets/logos/itvx.svg @@ -0,0 +1,2 @@ + +ITVx diff --git a/assets/logos/jellyfin.svg b/assets/logos/jellyfin.svg new file mode 100644 index 0000000..b62b754 --- /dev/null +++ b/assets/logos/jellyfin.svg @@ -0,0 +1,34 @@ + + + \ No newline at end of file diff --git a/assets/logos/joyn.svg b/assets/logos/joyn.svg new file mode 100644 index 0000000..16e17f6 --- /dev/null +++ b/assets/logos/joyn.svg @@ -0,0 +1,17 @@ + + + diff --git a/assets/logos/live-tv.svg b/assets/logos/live-tv.svg new file mode 100644 index 0000000..4e2c751 --- /dev/null +++ b/assets/logos/live-tv.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/movistar-plus.svg b/assets/logos/movistar-plus.svg new file mode 100644 index 0000000..690e157 --- /dev/null +++ b/assets/logos/movistar-plus.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/logos/netflix.svg b/assets/logos/netflix.svg new file mode 100644 index 0000000..0ce1bf4 --- /dev/null +++ b/assets/logos/netflix.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/logos/now-tv.svg b/assets/logos/now-tv.svg new file mode 100644 index 0000000..5afcb1c --- /dev/null +++ b/assets/logos/now-tv.svg @@ -0,0 +1,2 @@ + +NOW diff --git a/assets/logos/nrk-tv.svg b/assets/logos/nrk-tv.svg new file mode 100644 index 0000000..8805b54 --- /dev/null +++ b/assets/logos/nrk-tv.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/logos/paramount-plus.svg b/assets/logos/paramount-plus.svg new file mode 100644 index 0000000..9c92fa1 --- /dev/null +++ b/assets/logos/paramount-plus.svg @@ -0,0 +1,2 @@ + +Paramount+ diff --git a/assets/logos/peacock.svg b/assets/logos/peacock.svg new file mode 100644 index 0000000..1584ce3 --- /dev/null +++ b/assets/logos/peacock.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/logos/plex.svg b/assets/logos/plex.svg new file mode 100644 index 0000000..b9e05bf --- /dev/null +++ b/assets/logos/plex.svg @@ -0,0 +1,2 @@ + +Plex diff --git a/assets/logos/prime-video.svg b/assets/logos/prime-video.svg new file mode 100644 index 0000000..a5cb22b --- /dev/null +++ b/assets/logos/prime-video.svg @@ -0,0 +1,87 @@ + + + +Prime Video Logo + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/rakuten-tv.svg b/assets/logos/rakuten-tv.svg new file mode 100644 index 0000000..76d53fd --- /dev/null +++ b/assets/logos/rakuten-tv.svg @@ -0,0 +1,31 @@ + + + diff --git a/assets/logos/rtl-plus.svg b/assets/logos/rtl-plus.svg new file mode 100644 index 0000000..d3a89bc --- /dev/null +++ b/assets/logos/rtl-plus.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/skyshowtime.svg b/assets/logos/skyshowtime.svg new file mode 100644 index 0000000..80261a2 --- /dev/null +++ b/assets/logos/skyshowtime.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + diff --git a/assets/logos/spotify.svg b/assets/logos/spotify.svg new file mode 100644 index 0000000..a75a5e0 --- /dev/null +++ b/assets/logos/spotify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/logos/tv2-play.svg b/assets/logos/tv2-play.svg new file mode 100644 index 0000000..bf08bc1 --- /dev/null +++ b/assets/logos/tv2-play.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/logos/tving.svg b/assets/logos/tving.svg new file mode 100644 index 0000000..d56638d --- /dev/null +++ b/assets/logos/tving.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/logos/twitch.svg b/assets/logos/twitch.svg new file mode 100644 index 0000000..be993bc --- /dev/null +++ b/assets/logos/twitch.svg @@ -0,0 +1,2 @@ + +Twitch diff --git a/assets/logos/waipu-tv.svg b/assets/logos/waipu-tv.svg new file mode 100644 index 0000000..2ab3b0f --- /dev/null +++ b/assets/logos/waipu-tv.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/watcha.svg b/assets/logos/watcha.svg new file mode 100644 index 0000000..21d2172 --- /dev/null +++ b/assets/logos/watcha.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/logos/wavve.svg b/assets/logos/wavve.svg new file mode 100644 index 0000000..750458d --- /dev/null +++ b/assets/logos/wavve.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/logos/youtube.svg b/assets/logos/youtube.svg new file mode 100644 index 0000000..9cd8b57 --- /dev/null +++ b/assets/logos/youtube.svg @@ -0,0 +1,55 @@ + + + + diff --git a/assets/logos/zattoo.svg b/assets/logos/zattoo.svg new file mode 100644 index 0000000..320e019 --- /dev/null +++ b/assets/logos/zattoo.svg @@ -0,0 +1,23 @@ + + + diff --git a/assets/logos/zdf.svg b/assets/logos/zdf.svg new file mode 100644 index 0000000..4896847 --- /dev/null +++ b/assets/logos/zdf.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/assets/mark.svg b/assets/mark.svg new file mode 100644 index 0000000..aee1dbd --- /dev/null +++ b/assets/mark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/messages.json b/assets/messages.json new file mode 100644 index 0000000..8f74e09 --- /dev/null +++ b/assets/messages.json @@ -0,0 +1,1258 @@ +{ + "start": { + "en": "Start", + "de": "Start", + "es": "Inicio", + "zh": "主页", + "pt": "Início", + "it": "Home" + }, + "ha": { + "en": "Home", + "de": "Home", + "es": "Hogar", + "zh": "家居", + "pt": "Casa", + "it": "Casa" + }, + "apps": { + "en": "Apps", + "de": "Apps", + "es": "Aplicaciones", + "zh": "应用", + "pt": "Apps", + "it": "App" + }, + "inputs": { + "en": "Inputs", + "de": "Eingänge", + "es": "Entradas", + "zh": "输入源", + "pt": "Entradas", + "it": "Ingressi" + }, + "settings": { + "en": "Settings", + "de": "Einstellungen", + "es": "Ajustes", + "zh": "设置", + "pt": "Ajustes", + "it": "Impostazioni" + }, + "settingsRegion": { + "en": "Luma settings", + "de": "Luma-Einstellungen", + "es": "Ajustes de Luma", + "zh": "Luma 设置", + "pt": "Ajustes do Luma", + "it": "Impostazioni Luma" + }, + "mainNavigation": { + "en": "Main navigation", + "de": "Hauptnavigation", + "es": "Navegación principal", + "zh": "主导航", + "pt": "Navegação principal", + "it": "Navigazione principale" + }, + "settingsSections": { + "en": "Settings sections", + "de": "Einstellungsbereiche", + "es": "Secciones de ajustes", + "zh": "设置分类", + "pt": "Seções dos ajustes", + "it": "Sezioni impostazioni" + }, + "stateOn": { + "en": "On", + "de": "Ein", + "es": "Activado", + "zh": "开", + "pt": "Ligado", + "it": "Attivato" + }, + "stateOff": { + "en": "Off", + "de": "Aus", + "es": "Desactivado", + "zh": "关", + "pt": "Desligado", + "it": "Disattivato" + }, + "stateAuto": { + "en": "Auto", + "de": "Automatisch", + "es": "Automático", + "zh": "自动", + "pt": "Automático", + "it": "Automatico" + }, + "inputAlways": { + "en": "Always show", + "de": "Immer anzeigen", + "es": "Mostrar siempre", + "zh": "始终显示", + "pt": "Mostrar sempre", + "it": "Mostra sempre" + }, + "inputNever": { + "en": "Never show", + "de": "Nie anzeigen", + "es": "No mostrar nunca", + "zh": "从不显示", + "pt": "Nunca mostrar", + "it": "Non mostrare mai" + }, + "inputWhenConnected": { + "en": "Only show when connected", + "de": "Nur anzeigen, wenn verbunden", + "es": "Mostrar solo cuando esté conectada", + "zh": "仅在连接时显示", + "pt": "Mostrar só quando conectada", + "it": "Mostra solo se connesso" + }, + "renameInput": { + "en": "Rename", + "de": "Umbenennen", + "es": "Cambiar nombre", + "zh": "重命名", + "pt": "Renomear", + "it": "Rinomina" + }, + "inputName": { + "en": "Input name", + "de": "Name des Eingangs", + "es": "Nombre de la entrada", + "zh": "输入源名称", + "pt": "Nome da entrada", + "it": "Nome ingresso" + }, + "inputNameHint": { + "en": "Show a custom name instead of the port", + "de": "Eigenen Namen statt des Anschlusses anzeigen", + "es": "Mostrar un nombre propio en lugar del puerto", + "zh": "显示自定义名称而不是端口", + "pt": "Mostrar um nome próprio no lugar da porta", + "it": "Mostra un nome personalizzato invece della porta" + }, + "clearName": { + "en": "Clear name", + "de": "Name löschen", + "es": "Borrar nombre", + "zh": "清除名称", + "pt": "Limpar nome", + "it": "Cancella nome" + }, + "back": { + "en": "Back", + "de": "Zurück", + "es": "Atrás", + "zh": "返回", + "pt": "Voltar", + "it": "Indietro" + }, + "background": { + "en": "Background", + "de": "Hintergrund", + "es": "Fondo", + "zh": "背景", + "pt": "Plano de fundo", + "it": "Sfondo" + }, + "interface": { + "en": "General", + "de": "Allgemein", + "es": "General", + "zh": "通用", + "pt": "Geral", + "it": "Generale" + }, + "system": { + "en": "System", + "de": "System", + "es": "Sistema", + "zh": "系统", + "pt": "Sistema", + "it": "Sistema" + }, + "favorites": { + "en": "Favorites", + "de": "Favoriten", + "es": "Favoritos", + "zh": "收藏", + "pt": "Favoritos", + "it": "Preferiti" + }, + "weather": { + "en": "Weather", + "de": "Wetter", + "es": "Tiempo", + "zh": "天气", + "pt": "Clima", + "it": "Meteo" + }, + "location": { + "en": "Location", + "de": "Standort", + "es": "Ubicación", + "zh": "地点", + "pt": "Localização", + "it": "Posizione" + }, + "manageWeatherLocation": { + "en": "Set up location", + "de": "Standort einrichten", + "es": "Configurar ubicación", + "zh": "设置地点", + "pt": "Configurar localização", + "it": "Configura posizione" + }, + "locationResults": { + "en": "Location results", + "de": "Suchergebnisse", + "es": "Resultados", + "zh": "地点搜索结果", + "pt": "Resultados de localização", + "it": "Risultati posizione" + }, + "appNames": { + "en": "App names", + "de": "App-Namen", + "es": "Nombres de apps", + "zh": "应用名称", + "pt": "Nomes dos apps", + "it": "Nomi app" + }, + "showOnHome": { + "en": "Show on Home", + "de": "Auf der Startseite anzeigen", + "es": "Mostrar en Inicio", + "zh": "在主页显示", + "pt": "Mostrar no Início", + "it": "Mostra nella Home" + }, + "showOnApps": { + "en": "Show on Apps", + "de": "In Apps anzeigen", + "es": "Mostrar en Aplicaciones", + "zh": "在应用页显示", + "pt": "Mostrar em Apps", + "it": "Mostra in App" + }, + "clock24": { + "en": "24-hour clock", + "de": "24-Stunden-Uhr", + "es": "Reloj de 24 horas", + "zh": "24 小时制", + "pt": "Relógio de 24 horas", + "it": "Orologio 24 ore" + }, + "clockFormat": { + "en": "Clock format", + "de": "Uhrzeitformat", + "es": "Formato de hora", + "zh": "时钟格式", + "pt": "Formato do relógio", + "it": "Formato orologio" + }, + "clock": { + "en": "Clock", + "de": "Uhr", + "es": "Reloj", + "zh": "时钟", + "pt": "Relógio", + "it": "Orologio" + }, + "timeFormat": { + "en": "Time format", + "de": "Zeitformat", + "es": "Formato de visualización", + "zh": "时间显示", + "pt": "Formato de exibição", + "it": "Formato ora" + }, + "hour12": { + "en": "12-hour", + "de": "12 Stunden", + "es": "12 horas", + "zh": "12 小时制", + "pt": "12 horas", + "it": "12 ore" + }, + "hour24": { + "en": "24-hour", + "de": "24 Stunden", + "es": "24 horas", + "zh": "24 小时制", + "pt": "24 horas", + "it": "24 ore" + }, + "reducedMotion": { + "en": "Reduced motion", + "de": "Bewegungen reduzieren", + "es": "Reducir movimiento", + "zh": "减少动态效果", + "pt": "Reduzir animações", + "it": "Animazioni ridotte" + }, + "refreshApps": { + "en": "Refresh apps & inputs", + "de": "Apps und Eingänge aktualisieren", + "es": "Actualizar apps y entradas", + "zh": "刷新应用和输入源", + "pt": "Atualizar apps e entradas", + "it": "Aggiorna app e ingressi" + }, + "quitApp": { + "en": "Quit app", + "de": "App beenden", + "es": "Salir de la app", + "zh": "退出应用", + "pt": "Sair do app", + "it": "Chiudi app" + }, + "language": { + "en": "Language", + "de": "Anzeigesprache", + "es": "Idioma", + "zh": "显示语言", + "pt": "Idioma", + "it": "Lingua" + }, + "greetingName": { + "en": "Greeting name", + "de": "Name in der Begrüßung", + "es": "Nombre en el saludo", + "zh": "问候称呼", + "pt": "Nome na saudação", + "it": "Nome per il saluto" + }, + "greeting": { + "en": "Greeting", + "de": "Begrüßung", + "es": "Saludo", + "zh": "问候语", + "pt": "Saudação", + "it": "Saluto" + }, + "name": { + "en": "Name", + "de": "Name", + "es": "Nombre", + "zh": "名称", + "pt": "Nome", + "it": "Nome" + }, + "greetingNightNamed": { + "en": "Still up, {name}?", + "de": "Noch wach, {name}?", + "es": "¿Aún despiertos, {name}?", + "zh": "{name},还没睡吗?", + "pt": "Ainda acordado, {name}?", + "it": "Ancora in piedi, {name}?" + }, + "morningNamed": { + "en": "Good morning, {name}.", + "de": "Guten Morgen, {name}.", + "es": "Buenos días, {name}.", + "zh": "{name},早上好。", + "pt": "Bom dia, {name}.", + "it": "Buongiorno, {name}." + }, + "afternoonNamed": { + "en": "Good afternoon, {name}.", + "de": "Guten Tag, {name}.", + "es": "Buenas tardes, {name}.", + "zh": "{name},下午好。", + "pt": "Boa tarde, {name}.", + "it": "Buon pomeriggio, {name}." + }, + "eveningNamed": { + "en": "Good evening, {name}.", + "de": "Guten Abend, {name}.", + "es": "Buenas noches, {name}.", + "zh": "{name},晚上好。", + "pt": "Boa noite, {name}.", + "it": "Buonasera, {name}." + }, + "english": { + "en": "English", + "de": "English", + "es": "English", + "zh": "English", + "pt": "English", + "it": "English" + }, + "german": { + "en": "Deutsch", + "de": "Deutsch", + "es": "Deutsch", + "zh": "Deutsch", + "pt": "Deutsch", + "it": "Deutsch" + }, + "chinese": { + "en": "简体中文", + "de": "简体中文", + "es": "简体中文", + "zh": "简体中文", + "pt": "简体中文", + "it": "简体中文" + }, + "showWidget": { + "en": "Show widget", + "de": "Widget anzeigen", + "es": "Mostrar widget", + "zh": "显示天气组件", + "pt": "Mostrar widget", + "it": "Mostra widget" + }, + "temperature": { + "en": "Temperature", + "de": "Temperatur", + "es": "Temperatura", + "zh": "温度", + "pt": "Temperatura", + "it": "Temperatura" + }, + "celsius": { + "en": "Celsius", + "de": "Celsius", + "es": "Celsius", + "zh": "摄氏度", + "pt": "Celsius", + "it": "Celsius" + }, + "fahrenheit": { + "en": "Fahrenheit", + "de": "Fahrenheit", + "es": "Fahrenheit", + "zh": "华氏度", + "pt": "Fahrenheit", + "it": "Fahrenheit" + }, + "cityPostcode": { + "en": "City or postcode", + "de": "Stadt oder Postleitzahl", + "es": "Ciudad o código postal", + "zh": "城市或邮政编码", + "pt": "Cidade ou CEP", + "it": "Città o CAP" + }, + "searchLocations": { + "en": "Search locations", + "de": "Orte suchen", + "es": "Buscar ubicaciones", + "zh": "搜索地点", + "pt": "Buscar localização", + "it": "Cerca località" + }, + "removeWeatherLocation": { + "en": "Remove weather location", + "de": "Wetterort entfernen", + "es": "Eliminar ubicación del tiempo", + "zh": "移除天气地点", + "pt": "Remover localização do clima", + "it": "Rimuovi posizione meteo" + }, + "enterCity": { + "en": "Enter a city or postcode with at least two characters.", + "de": "Gib mindestens zwei Zeichen für eine Stadt oder Postleitzahl ein.", + "es": "Escribe una ciudad o un código postal con al menos dos caracteres.", + "zh": "请输入至少两个字符的城市或邮政编码。", + "pt": "Digite uma cidade ou CEP com pelo menos dois caracteres.", + "it": "Inserisci una città o un CAP di almeno due caratteri." + }, + "noLocations": { + "en": "No locations found. Try another city or postcode.", + "de": "Keine Orte gefunden. Versuche eine andere Stadt oder Postleitzahl.", + "es": "Sin resultados. Prueba con otra ciudad o código postal.", + "zh": "未找到地点,请尝试其他城市或邮政编码。", + "pt": "Nenhum lugar encontrado. Tente outra cidade ou CEP.", + "it": "Nessuna località trovata. Prova con un'altra città o CAP." + }, + "searchUnavailable": { + "en": "Location search is unavailable. Check your connection and try again.", + "de": "Die Ortssuche ist nicht verfügbar. Prüfe deine Verbindung und versuche es erneut.", + "es": "Búsqueda no disponible. Revisa tu conexión e inténtalo de nuevo.", + "zh": "地点搜索不可用。请检查网络连接后重试。", + "pt": "A busca de localização está indisponível. Verifique a conexão e tente de novo.", + "it": "Ricerca località non disponibile. Controlla la connessione e riprova." + }, + "findApp": { + "en": "Find an app", + "de": "App suchen", + "es": "Buscar una app", + "zh": "搜索应用", + "pt": "Buscar um app", + "it": "Trova un'app" + }, + "clearCategory": { + "en": "Clear category filter", + "de": "Kategoriefilter löschen", + "es": "Borrar filtro de categoría", + "zh": "清除分类筛选", + "pt": "Limpar filtro de categoria", + "it": "Rimuovi filtro categoria" + }, + "appCategories": { + "en": "App categories", + "de": "App-Kategorien", + "es": "Categorías de apps", + "zh": "应用分类", + "pt": "Categorias de apps", + "it": "Categorie app" + }, + "hidden": { + "en": "Hidden ({count})", + "de": "Ausgeblendet ({count})", + "es": "Ocultas ({count})", + "zh": "已隐藏({count})", + "pt": "Ocultos ({count})", + "it": "Nascoste ({count})" + }, + "noMatches": { + "en": "Nothing here. Yet.", + "de": "Hier ist noch nichts.", + "es": "Nada por aquí. Todavía.", + "zh": "这里还没有内容。", + "pt": "Nada por aqui. Ainda.", + "it": "Qui non c'è niente. Per ora." + }, + "noHidden": { + "en": "No hidden apps.", + "de": "Keine ausgeblendeten Apps.", + "es": "No hay apps ocultas.", + "zh": "没有隐藏的应用。", + "pt": "Nenhum app oculto.", + "it": "Nessuna app nascosta." + }, + "roomForNew": { + "en": "A little room for something new.", + "de": "Hier ist Platz für etwas Neues.", + "es": "Un hueco para algo nuevo.", + "zh": "这里可以放入新应用。", + "pt": "Um espacinho para algo novo.", + "it": "Un po' di spazio per qualcosa di nuovo." + }, + "findingApps": { + "en": "Finding your apps…", + "de": "Apps werden gesucht…", + "es": "Buscando tus apps…", + "zh": "正在查找应用…", + "pt": "Procurando seus apps…", + "it": "Ricerca delle tue app…" + }, + "noCategoryApps": { + "en": "Apps in this category will appear here.", + "de": "Apps dieser Kategorie werden hier angezeigt.", + "es": "Las apps de esta categoría aparecerán aquí.", + "zh": "此分类中的应用会显示在这里。", + "pt": "Os apps desta categoria aparecerão aqui.", + "it": "Le app di questa categoria appariranno qui." + }, + "hiddenHint": { + "en": "Apps you hide will appear here.", + "de": "Ausgeblendete Apps erscheinen hier.", + "es": "Las apps que ocultes aparecerán aquí.", + "zh": "隐藏的应用会显示在这里。", + "pt": "Os apps que você ocultar aparecerão aqui.", + "it": "Le app che nascondi appariranno qui." + }, + "noMatchesHint": { + "en": "No apps match “{query}”. Try another name.", + "de": "Keine Apps entsprechen „{query}“. Versuche einen anderen Namen.", + "es": "Ninguna app coincide con “{query}”. Prueba con otro nombre.", + "zh": "没有与“{query}”匹配的应用。请尝试其他名称。", + "pt": "Nenhum app corresponde a “{query}”. Tente outro nome.", + "it": "Nessuna app corrisponde a “{query}”. Prova un altro nome." + }, + "greetingNight": { + "en": "One more moment.", + "de": "Einen Moment noch.", + "es": "Un momento más.", + "zh": "稍等片刻。", + "pt": "Mais um momento.", + "it": "Ancora un momento." + }, + "morning": { + "en": "Good morning.", + "de": "Guten Morgen.", + "es": "Buenos días.", + "zh": "早上好。", + "pt": "Bom dia.", + "it": "Buongiorno." + }, + "afternoon": { + "en": "Good afternoon.", + "de": "Guten Tag.", + "es": "Buenas tardes.", + "zh": "下午好。", + "pt": "Boa tarde.", + "it": "Buon pomeriggio." + }, + "evening": { + "en": "Good evening.", + "de": "Guten Abend.", + "es": "Buenas noches.", + "zh": "晚上好。", + "pt": "Boa noite.", + "it": "Buonasera." + }, + "aLittleHelp": { + "en": "A little help from your TV.", + "de": "Ein wenig Hilfe von deinem Fernseher.", + "es": "Una pequeña ayuda de tu tele.", + "zh": "电视需要一点帮助。", + "pt": "Uma ajudinha da sua TV.", + "it": "Un piccolo aiuto dalla tua TV." + }, + "makeFavorites": { + "en": "Make room for your favorites.", + "de": "Deine Favoriten verdienen einen Platz.", + "es": "Haz sitio para tus favoritas.", + "zh": "把喜欢的应用加入收藏。", + "pt": "Abra espaço para seus favoritos.", + "it": "Fai spazio ai tuoi preferiti." + }, + "pinHint": { + "en": "Find an app you love. Hold OK to pin it here.", + "de": "Finde eine App und halte OK, um sie hier anzupinnen.", + "es": "Encuentra una app que te guste. Mantén OK para fijarla aquí.", + "zh": "找到喜欢的应用,长按 OK 将其固定在这里。", + "pt": "Encontre um app que você adora. Segure OK para fixá-lo aqui.", + "it": "Trova un'app che ami. Tieni premuto OK per fissarla qui." + }, + "exploreApps": { + "en": "Explore your apps", + "de": "Apps entdecken", + "es": "Explora tus apps", + "zh": "浏览应用", + "pt": "Explorar seus apps", + "it": "Esplora le tue app" + }, + "moveApps": { + "en": "Move apps", + "de": "Apps verschieben", + "es": "Mover apps", + "zh": "移动应用", + "pt": "Mover apps", + "it": "Sposta app" + }, + "inputsHeading": { + "en": "Inputs", + "de": "Eingänge", + "es": "Entradas", + "zh": "输入源", + "pt": "Entradas", + "it": "Ingressi" + }, + "noInputs": { + "en": "No inputs to show.", + "de": "Keine Eingänge verfügbar.", + "es": "No hay entradas que mostrar.", + "zh": "没有可用的输入源。", + "pt": "Nenhuma entrada para mostrar.", + "it": "Nessun ingresso da mostrare." + }, + "findingInputs": { + "en": "Finding your inputs…", + "de": "Eingänge werden gesucht…", + "es": "Buscando tus entradas…", + "zh": "正在查找输入源…", + "pt": "Procurando suas entradas…", + "it": "Ricerca dei tuoi ingressi…" + }, + "physicalInputs": { + "en": "Your TV’s physical HDMI inputs will appear here.", + "de": "Die HDMI-Eingänge deines Fernsehers erscheinen hier.", + "es": "Las entradas HDMI de tu tele aparecerán aquí.", + "zh": "电视的 HDMI 输入源会显示在这里。", + "pt": "As entradas HDMI físicas da sua TV aparecerão aqui.", + "it": "Gli ingressi HDMI fisici della tua TV appariranno qui." + }, + "refreshInputs": { + "en": "Refresh inputs", + "de": "Eingänge aktualisieren", + "es": "Actualizar entradas", + "zh": "刷新输入源", + "pt": "Atualizar entradas", + "it": "Aggiorna ingressi" + }, + "retry": { + "en": "Try again", + "de": "Erneut versuchen", + "es": "Reintentar", + "zh": "重试", + "pt": "Tentar de novo", + "it": "Riprova" + }, + "open": { + "en": "Open", + "de": "Öffnen", + "es": "Abrir", + "zh": "打开", + "pt": "Abrir", + "it": "Apri" + }, + "select": { + "en": "Select", + "de": "Auswählen", + "es": "Seleccionar", + "zh": "选择", + "pt": "Selecionar", + "it": "Seleziona" + }, + "move": { + "en": "Move", + "de": "Verschieben", + "es": "Mover", + "zh": "移动", + "pt": "Mover", + "it": "Sposta" + }, + "done": { + "en": "Done", + "de": "Fertig", + "es": "Listo", + "zh": "完成", + "pt": "Concluir", + "it": "Fatto" + }, + "close": { + "en": "Close", + "de": "Schließen", + "es": "Cerrar", + "zh": "关闭", + "pt": "Fechar", + "it": "Chiudi" + }, + "homeLabel": { + "en": "Home", + "de": "Startseite", + "es": "Inicio", + "zh": "主页", + "pt": "Início", + "it": "Home" + }, + "options": { + "en": "Hold OK for options", + "de": "OK halten für Optionen", + "es": "Mantén OK para ver opciones", + "zh": "长按 OK 查看选项", + "pt": "Segure OK para opções", + "it": "Tieni premuto OK per le opzioni" + }, + "connected": { + "en": "Connected", + "de": "Verbunden", + "es": "Conectado", + "zh": "已连接", + "pt": "Conectado", + "it": "Connesso" + }, + "noDevice": { + "en": "No device connected", + "de": "Kein Gerät verbunden", + "es": "Ningún dispositivo conectado", + "zh": "未连接设备", + "pt": "Nenhum dispositivo conectado", + "it": "Nessun dispositivo connesso" + }, + "externalInput": { + "en": "External input", + "de": "Externer Eingang", + "es": "Entrada externa", + "zh": "外部输入源", + "pt": "Entrada externa", + "it": "Ingresso esterno" + }, + "addFavorite": { + "en": "Add to favorites", + "de": "Zu Favoriten hinzufügen", + "es": "Añadir a favoritos", + "zh": "添加到收藏", + "pt": "Adicionar aos favoritos", + "it": "Aggiungi ai preferiti" + }, + "removeFavorite": { + "en": "Remove from favorites", + "de": "Aus Favoriten entfernen", + "es": "Quitar de favoritos", + "zh": "从收藏中移除", + "pt": "Remover dos favoritos", + "it": "Rimuovi dai preferiti" + }, + "hideApp": { + "en": "Hide app", + "de": "App ausblenden", + "es": "Ocultar app", + "zh": "隐藏应用", + "pt": "Ocultar app", + "it": "Nascondi app" + }, + "showApp": { + "en": "Show app", + "de": "App anzeigen", + "es": "Mostrar app", + "zh": "显示应用", + "pt": "Mostrar app", + "it": "Mostra app" + }, + "favorite": { + "en": "Favorite", + "de": "Favorit", + "es": "Favorita", + "zh": "收藏", + "pt": "Favorito", + "it": "Preferito" + }, + "tvSettings": { + "en": "TV settings", + "de": "TV-Einstellungen", + "es": "Ajustes del televisor", + "zh": "电视设置", + "pt": "Configurações da TV", + "it": "Impostazioni TV" + }, + "loadingWeather": { + "en": "Loading weather…", + "de": "Wetter wird geladen…", + "es": "Cargando el tiempo…", + "zh": "正在加载天气…", + "pt": "Carregando clima…", + "it": "Caricamento meteo…" + }, + "weatherUnavailable": { + "en": "Weather unavailable", + "de": "Wetter nicht verfügbar", + "es": "Tiempo no disponible", + "zh": "天气不可用", + "pt": "Clima indisponível", + "it": "Meteo non disponibile" + }, + "setWeatherLocation": { + "en": "Set weather location", + "de": "Wetterort festlegen", + "es": "Fijar ubicación del tiempo", + "zh": "设置天气地点", + "pt": "Definir localização do clima", + "it": "Imposta posizione meteo" + }, + "configureWeather": { + "en": "Configure weather location", + "de": "Wetterort konfigurieren", + "es": "Configurar ubicación del tiempo", + "zh": "配置天气地点", + "pt": "Configurar localização do clima", + "it": "Configura posizione meteo" + }, + "weatherInLocation": { + "en": "Weather in {city}. Configure weather location.", + "de": "Wetter in {city}. Wetterort konfigurieren.", + "es": "El tiempo en {city}. Configura la ubicación.", + "zh": "{city}的天气。配置天气地点。", + "pt": "Clima em {city}. Configurar localização do clima.", + "it": "Meteo a {city}. Configura posizione meteo." + }, + "lastReading": { + "en": "Last saved reading", + "de": "Letzter gespeicherter Wert", + "es": "Último dato guardado", + "zh": "上次保存的数据", + "pt": "Última leitura salva", + "it": "Ultima lettura salvata" + }, + "selectClose": { + "en": "Select", + "de": "Auswählen", + "es": "Elegir", + "zh": "选择", + "pt": "Selecionar", + "it": "Seleziona" + }, + "closeDialog": { + "en": "Close", + "de": "Schließen", + "es": "Cerrar", + "zh": "关闭", + "pt": "Fechar", + "it": "Chiudi" + }, + "dismissNotification": { + "en": "Dismiss notification", + "de": "Meldung schließen", + "es": "Descartar aviso", + "zh": "关闭通知", + "pt": "Dispensar notificação", + "it": "Ignora notifica" + }, + "opening": { + "en": "Opening…", + "de": "Wird geöffnet…", + "es": "Abriendo…", + "zh": "正在打开…", + "pt": "Abrindo…", + "it": "Apertura…" + }, + "desktopPreview": { + "en": "Desktop preview · {app} will open on your TV.", + "de": "Desktop-Vorschau · {app} wird auf deinem Fernseher geöffnet.", + "es": "Vista previa · {app} se abrirá en tu tele.", + "zh": "桌面预览 · {app}将在电视上打开。", + "pt": "Prévia no computador · {app} abrirá na sua TV.", + "it": "Anteprima desktop · {app} si aprirà sulla tua TV." + }, + "favoriteRemoved": { + "en": "{app} removed from favorites.", + "de": "{app} aus Favoriten entfernt.", + "es": "{app} eliminada de favoritos.", + "zh": "已从收藏中移除 {app}。", + "pt": "{app} removido dos favoritos.", + "it": "{app} rimossa dai preferiti." + }, + "favoriteAdded": { + "en": "{app} added to favorites.", + "de": "{app} zu Favoriten hinzugefügt.", + "es": "{app} añadida a favoritos.", + "zh": "已将 {app} 添加到收藏。", + "pt": "{app} adicionado aos favoritos.", + "it": "{app} aggiunta ai preferiti." + }, + "appRestored": { + "en": "{app} is back in your library.", + "de": "{app} ist wieder in deiner Bibliothek.", + "es": "{app} vuelve a estar en tu biblioteca.", + "zh": "{app}已恢复到应用库。", + "pt": "{app} voltou para a sua biblioteca.", + "it": "{app} è di nuovo nella tua libreria." + }, + "appHidden": { + "en": "{app} hidden. You can restore it from Apps.", + "de": "{app} ausgeblendet. Du kannst sie unter Apps wiederherstellen.", + "es": "{app} oculta. Puedes recuperarla en Aplicaciones.", + "zh": "已隐藏 {app}。你可以在“应用”中恢复。", + "pt": "{app} oculto. Você pode restaurá-lo em Apps.", + "it": "{app} nascosta. Puoi ripristinarla da App." + }, + "refreshNotice": { + "en": "Refreshing your apps and inputs…", + "de": "Apps und Eingänge werden aktualisiert…", + "es": "Actualizando tus apps y entradas…", + "zh": "正在刷新应用和输入源…", + "pt": "Atualizando seus apps e entradas…", + "it": "Aggiornamento di app e ingressi…" + }, + "savedError": { + "en": "Your changes couldn’t be saved.", + "de": "Änderungen konnten nicht gespeichert werden.", + "es": "No se pudieron guardar los cambios.", + "zh": "无法保存更改。", + "pt": "Não foi possível salvar as alterações. O armazenamento pode estar cheio.", + "it": "Impossibile salvare le modifiche." + }, + "tvUnreachable": { + "en": "Your TV couldn’t be reached.", + "de": "Der Fernseher ist nicht erreichbar.", + "es": "No se puede conectar con tu tele.", + "zh": "无法连接电视。", + "pt": "Não foi possível acessar a TV.", + "it": "Impossibile raggiungere la TV." + }, + "appOpenError": { + "en": "This app couldn’t be opened.", + "de": "Diese App konnte nicht geöffnet werden.", + "es": "No se pudo abrir esta app.", + "zh": "无法打开此应用。", + "pt": "Não foi possível abrir este app.", + "it": "Impossibile aprire questa app." + }, + "dismissSearch": { + "en": "Clear search", + "de": "Suche löschen", + "es": "Borrar búsqueda", + "zh": "清除搜索", + "pt": "Limpar busca", + "it": "Cancella ricerca" + }, + "languageMenu": { + "en": "Language", + "de": "Anzeigesprache", + "es": "Idioma", + "zh": "显示语言", + "pt": "Idioma", + "it": "Lingua" + }, + "slate": { + "en": "Slate", + "de": "Schiefer", + "es": "Pizarra", + "zh": "石板", + "pt": "Ardósia", + "it": "Ardesia" + }, + "midnight": { + "en": "Midnight", + "de": "Mitternacht", + "es": "Medianoche", + "zh": "午夜", + "pt": "Meia-noite", + "it": "Mezzanotte" + }, + "prism": { + "en": "Prism", + "de": "Prisma", + "es": "Prisma", + "zh": "棱镜", + "pt": "Prisma", + "it": "Prisma" + }, + "monumentValley": { + "en": "Monument Valley", + "de": "Monument Valley", + "es": "Monument Valley", + "zh": "纪念碑谷", + "pt": "Monument Valley", + "it": "Monument Valley" + }, + "sanFrancisco": { + "en": "San Francisco", + "de": "San Francisco", + "es": "San Francisco", + "zh": "旧金山", + "pt": "São Francisco", + "it": "San Francisco" + }, + "summit": { + "en": "Summit", + "de": "Gipfel", + "es": "Cumbre", + "zh": "山巅", + "pt": "Cume", + "it": "Vetta" + }, + "forest": { + "en": "Forest", + "de": "Wald", + "es": "Bosque", + "zh": "森林", + "pt": "Floresta", + "it": "Foresta" + }, + "yosemite": { + "en": "Yosemite", + "de": "Yosemite", + "es": "Yosemite", + "zh": "优胜美地", + "pt": "Yosemite", + "it": "Yosemite" + }, + "northernLights": { + "en": "Northern lights", + "de": "Nordlichter", + "es": "Auroras boreales", + "zh": "北极光", + "pt": "Aurora boreal", + "it": "Aurora boreale" + }, + "liveTV": { + "en": "Live TV", + "de": "Live-TV", + "es": "TV en directo", + "zh": "电视直播", + "pt": "TV ao vivo", + "it": "TV in diretta" + }, + "homebrew": { + "en": "Homebrew", + "de": "Homebrew", + "es": "Homebrew", + "zh": "自制应用商店", + "pt": "Homebrew", + "it": "Homebrew" + }, + "browser": { + "en": "Browser", + "de": "Browser", + "es": "Navegador", + "zh": "浏览器", + "pt": "Navegador", + "it": "Browser" + }, + "categoryStreaming": { + "en": "Streaming", + "de": "Streaming", + "es": "Streaming", + "zh": "流媒体", + "pt": "Streaming", + "it": "Streaming" + }, + "categoryMusic": { + "en": "Music", + "de": "Musik", + "es": "Música", + "zh": "音乐", + "pt": "Música", + "it": "Musica" + }, + "categoryGaming": { + "en": "Gaming", + "de": "Spiele", + "es": "Juegos", + "zh": "游戏", + "pt": "Jogos", + "it": "Giochi" + }, + "categoryUtilities": { + "en": "Utilities", + "de": "Werkzeuge", + "es": "Utilidades", + "zh": "工具", + "pt": "Utilitários", + "it": "Utilità" + }, + "clearSkies": { + "en": "Clear skies", + "de": "Klarer Himmel", + "es": "Despejado", + "zh": "晴朗", + "pt": "Céu limpo", + "it": "Cielo sereno" + }, + "clearNight": { + "en": "Clear night", + "de": "Klare Nacht", + "es": "Noche despejada", + "zh": "晴朗的夜晚", + "pt": "Noite limpa", + "it": "Notte serena" + }, + "mostlyClear": { + "en": "Mostly clear", + "de": "Überwiegend klar", + "es": "Casi despejado", + "zh": "大致晴朗", + "pt": "Quase limpo", + "it": "Perlopiù sereno" + }, + "partlyCloudy": { + "en": "Partly cloudy", + "de": "Teilweise bewölkt", + "es": "Parcialmente nublado", + "zh": "局部多云", + "pt": "Parcialmente nublado", + "it": "Parzialmente nuvoloso" + }, + "overcast": { + "en": "Overcast", + "de": "Bedeckt", + "es": "Cubierto", + "zh": "阴天", + "pt": "Nublado", + "it": "Coperto" + }, + "fog": { + "en": "Fog", + "de": "Nebel", + "es": "Niebla", + "zh": "雾", + "pt": "Neblina", + "it": "Nebbia" + }, + "drizzle": { + "en": "Drizzle", + "de": "Nieselregen", + "es": "Llovizna", + "zh": "毛毛雨", + "pt": "Garoa", + "it": "Pioviggine" + }, + "rain": { + "en": "Rain", + "de": "Regen", + "es": "Lluvia", + "zh": "雨", + "pt": "Chuva", + "it": "Pioggia" + }, + "snow": { + "en": "Snow", + "de": "Schnee", + "es": "Nieve", + "zh": "雪", + "pt": "Neve", + "it": "Neve" + }, + "thunderstorms": { + "en": "Thunderstorms", + "de": "Gewitter", + "es": "Tormentas", + "zh": "雷暴", + "pt": "Tempestades", + "it": "Temporali" + }, + "currentWeather": { + "en": "Current weather", + "de": "Aktuelles Wetter", + "es": "Tiempo actual", + "zh": "当前天气", + "pt": "Clima atual", + "it": "Meteo attuale" + }, + "searching": { + "en": "Searching…", + "de": "Suche läuft…", + "es": "Buscando…", + "zh": "正在搜索…", + "pt": "Buscando…", + "it": "Ricerca…" + }, + "space": { + "en": "Space", + "de": "Leerzeichen", + "es": "Espacio", + "zh": "空格", + "pt": "Espaço", + "it": "Spazio" + }, + "delete": { + "en": "Delete", + "de": "Löschen", + "es": "Eliminar", + "zh": "删除", + "pt": "Apagar", + "it": "Elimina" + }, + "clear": { + "en": "Clear", + "de": "Leeren", + "es": "Borrar", + "zh": "清除", + "pt": "Limpar", + "it": "Cancella" + }, + "hiddenLabel": { + "en": "Hidden", + "de": "Ausgeblendet", + "es": "Oculta", + "zh": "已隐藏", + "pt": "Ocultos", + "it": "Nascoste" + }, + "scrollTestApp": { + "en": "Scroll test", + "de": "Scroll-Test", + "es": "Prueba de desplazamiento", + "zh": "滚动测试", + "pt": "Teste de rolagem", + "it": "Test scorrimento" + }, + "scrollTestOnly": { + "en": "Test card for scrolling; no app to launch.", + "de": "Testkarte zum Scrollen; keine App zum Öffnen.", + "es": "Tarjeta de prueba; no hay app que abrir.", + "zh": "滚动测试卡片,没有可启动的应用。", + "pt": "Card de teste para rolagem; não abre nenhum app.", + "it": "Scheda di test per lo scorrimento; nessuna app da avviare." + } +} diff --git a/assets/ui-icons/alert.svg b/assets/ui-icons/alert.svg new file mode 100644 index 0000000..21af815 --- /dev/null +++ b/assets/ui-icons/alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/arrow.svg b/assets/ui-icons/arrow.svg new file mode 100644 index 0000000..a98f449 --- /dev/null +++ b/assets/ui-icons/arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/check.svg b/assets/ui-icons/check.svg new file mode 100644 index 0000000..6ceed87 --- /dev/null +++ b/assets/ui-icons/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/clock.svg b/assets/ui-icons/clock.svg new file mode 100644 index 0000000..818fef7 --- /dev/null +++ b/assets/ui-icons/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/close.svg b/assets/ui-icons/close.svg new file mode 100644 index 0000000..a5748e4 --- /dev/null +++ b/assets/ui-icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/coffee.svg b/assets/ui-icons/coffee.svg new file mode 100644 index 0000000..5dac0f2 --- /dev/null +++ b/assets/ui-icons/coffee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/eye-off.svg b/assets/ui-icons/eye-off.svg new file mode 100644 index 0000000..d3271c6 --- /dev/null +++ b/assets/ui-icons/eye-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/eye.svg b/assets/ui-icons/eye.svg new file mode 100644 index 0000000..c21991f --- /dev/null +++ b/assets/ui-icons/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/gaming.svg b/assets/ui-icons/gaming.svg new file mode 100644 index 0000000..2ea636d --- /dev/null +++ b/assets/ui-icons/gaming.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/generic.svg b/assets/ui-icons/generic.svg new file mode 100644 index 0000000..adefc5a --- /dev/null +++ b/assets/ui-icons/generic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/globe.svg b/assets/ui-icons/globe.svg new file mode 100644 index 0000000..d2a4b7f --- /dev/null +++ b/assets/ui-icons/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/grid.svg b/assets/ui-icons/grid.svg new file mode 100644 index 0000000..e1d8162 --- /dev/null +++ b/assets/ui-icons/grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/heart-filled.svg b/assets/ui-icons/heart-filled.svg new file mode 100644 index 0000000..4964bcc --- /dev/null +++ b/assets/ui-icons/heart-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/heart.svg b/assets/ui-icons/heart.svg new file mode 100644 index 0000000..8830874 --- /dev/null +++ b/assets/ui-icons/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/input-apple-tv.svg b/assets/ui-icons/input-apple-tv.svg new file mode 100644 index 0000000..a8a507a --- /dev/null +++ b/assets/ui-icons/input-apple-tv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/input-computer.svg b/assets/ui-icons/input-computer.svg new file mode 100644 index 0000000..73f063d --- /dev/null +++ b/assets/ui-icons/input-computer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/input-console.svg b/assets/ui-icons/input-console.svg new file mode 100644 index 0000000..d871b57 --- /dev/null +++ b/assets/ui-icons/input-console.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/input-hdmi.svg b/assets/ui-icons/input-hdmi.svg new file mode 100644 index 0000000..5eb12c5 --- /dev/null +++ b/assets/ui-icons/input-hdmi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/input-playstation.svg b/assets/ui-icons/input-playstation.svg new file mode 100644 index 0000000..523eb1a --- /dev/null +++ b/assets/ui-icons/input-playstation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/input-streamer.svg b/assets/ui-icons/input-streamer.svg new file mode 100644 index 0000000..5274d67 --- /dev/null +++ b/assets/ui-icons/input-streamer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/input-usb.svg b/assets/ui-icons/input-usb.svg new file mode 100644 index 0000000..5d75ccf --- /dev/null +++ b/assets/ui-icons/input-usb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/kodi.svg b/assets/ui-icons/kodi.svg new file mode 100644 index 0000000..79d8dd9 --- /dev/null +++ b/assets/ui-icons/kodi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/language.svg b/assets/ui-icons/language.svg new file mode 100644 index 0000000..9d6f6e8 --- /dev/null +++ b/assets/ui-icons/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/left.svg b/assets/ui-icons/left.svg new file mode 100644 index 0000000..27223c2 --- /dev/null +++ b/assets/ui-icons/left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/location.svg b/assets/ui-icons/location.svg new file mode 100644 index 0000000..9139417 --- /dev/null +++ b/assets/ui-icons/location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/moonlight.svg b/assets/ui-icons/moonlight.svg new file mode 100644 index 0000000..781d1b2 --- /dev/null +++ b/assets/ui-icons/moonlight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/more.svg b/assets/ui-icons/more.svg new file mode 100644 index 0000000..4724692 --- /dev/null +++ b/assets/ui-icons/more.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/motion.svg b/assets/ui-icons/motion.svg new file mode 100644 index 0000000..d8f6c4a --- /dev/null +++ b/assets/ui-icons/motion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/power.svg b/assets/ui-icons/power.svg new file mode 100644 index 0000000..b2cbbb5 --- /dev/null +++ b/assets/ui-icons/power.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/refresh.svg b/assets/ui-icons/refresh.svg new file mode 100644 index 0000000..6587a2e --- /dev/null +++ b/assets/ui-icons/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/right.svg b/assets/ui-icons/right.svg new file mode 100644 index 0000000..956d060 --- /dev/null +++ b/assets/ui-icons/right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/search.svg b/assets/ui-icons/search.svg new file mode 100644 index 0000000..09f7413 --- /dev/null +++ b/assets/ui-icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/settings.svg b/assets/ui-icons/settings.svg new file mode 100644 index 0000000..847aef4 --- /dev/null +++ b/assets/ui-icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/sparkles.svg b/assets/ui-icons/sparkles.svg new file mode 100644 index 0000000..359f378 --- /dev/null +++ b/assets/ui-icons/sparkles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/spotify.svg b/assets/ui-icons/spotify.svg new file mode 100644 index 0000000..66c4d77 --- /dev/null +++ b/assets/ui-icons/spotify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/thermometer.svg b/assets/ui-icons/thermometer.svg new file mode 100644 index 0000000..0d3e400 --- /dev/null +++ b/assets/ui-icons/thermometer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/tv.svg b/assets/ui-icons/tv.svg new file mode 100644 index 0000000..9ed1ece --- /dev/null +++ b/assets/ui-icons/tv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/type.svg b/assets/ui-icons/type.svg new file mode 100644 index 0000000..c266866 --- /dev/null +++ b/assets/ui-icons/type.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-cloud.svg b/assets/ui-icons/weather-cloud.svg new file mode 100644 index 0000000..2095b13 --- /dev/null +++ b/assets/ui-icons/weather-cloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-drizzle.svg b/assets/ui-icons/weather-drizzle.svg new file mode 100644 index 0000000..e70a785 --- /dev/null +++ b/assets/ui-icons/weather-drizzle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-fog.svg b/assets/ui-icons/weather-fog.svg new file mode 100644 index 0000000..77fa0c7 --- /dev/null +++ b/assets/ui-icons/weather-fog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-moon.svg b/assets/ui-icons/weather-moon.svg new file mode 100644 index 0000000..507830e --- /dev/null +++ b/assets/ui-icons/weather-moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-partly-cloudy.svg b/assets/ui-icons/weather-partly-cloudy.svg new file mode 100644 index 0000000..b7a7a49 --- /dev/null +++ b/assets/ui-icons/weather-partly-cloudy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-rain.svg b/assets/ui-icons/weather-rain.svg new file mode 100644 index 0000000..2cd2106 --- /dev/null +++ b/assets/ui-icons/weather-rain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-snow.svg b/assets/ui-icons/weather-snow.svg new file mode 100644 index 0000000..de97290 --- /dev/null +++ b/assets/ui-icons/weather-snow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-storm.svg b/assets/ui-icons/weather-storm.svg new file mode 100644 index 0000000..da82d2b --- /dev/null +++ b/assets/ui-icons/weather-storm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui-icons/weather-sun.svg b/assets/ui-icons/weather-sun.svg new file mode 100644 index 0000000..09c587d --- /dev/null +++ b/assets/ui-icons/weather-sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/wallpapers/dunes.jpg b/assets/wallpapers/dunes.jpg new file mode 100644 index 0000000..b486c0a Binary files /dev/null and b/assets/wallpapers/dunes.jpg differ diff --git a/assets/wallpapers/forest.jpg b/assets/wallpapers/forest.jpg new file mode 100644 index 0000000..c2e6468 Binary files /dev/null and b/assets/wallpapers/forest.jpg differ diff --git a/assets/wallpapers/midnight.svg b/assets/wallpapers/midnight.svg new file mode 100644 index 0000000..b046d6e --- /dev/null +++ b/assets/wallpapers/midnight.svg @@ -0,0 +1 @@ + diff --git a/assets/wallpapers/northern-lights.jpg b/assets/wallpapers/northern-lights.jpg new file mode 100644 index 0000000..b570a98 Binary files /dev/null and b/assets/wallpapers/northern-lights.jpg differ diff --git a/assets/wallpapers/prism.svg b/assets/wallpapers/prism.svg new file mode 100644 index 0000000..14d81c4 --- /dev/null +++ b/assets/wallpapers/prism.svg @@ -0,0 +1 @@ + diff --git a/assets/wallpapers/san-francisco.jpg b/assets/wallpapers/san-francisco.jpg new file mode 100644 index 0000000..3cf8c18 Binary files /dev/null and b/assets/wallpapers/san-francisco.jpg differ diff --git a/assets/wallpapers/slate.svg b/assets/wallpapers/slate.svg new file mode 100644 index 0000000..3b898dd --- /dev/null +++ b/assets/wallpapers/slate.svg @@ -0,0 +1 @@ + diff --git a/assets/wallpapers/summit.jpg b/assets/wallpapers/summit.jpg new file mode 100644 index 0000000..918605e Binary files /dev/null and b/assets/wallpapers/summit.jpg differ diff --git a/assets/wallpapers/yosemite.jpg b/assets/wallpapers/yosemite.jpg new file mode 100644 index 0000000..b294bdb Binary files /dev/null and b/assets/wallpapers/yosemite.jpg differ diff --git a/development.md b/development.md new file mode 100644 index 0000000..b92e59c --- /dev/null +++ b/development.md @@ -0,0 +1,529 @@ +# Luma development + +## Layout + +```text +Cargo.toml / Cargo.lock Rust workspace and pinned dependencies +src/ Rust launcher, platform adapter and GLES2 shaders +assets/ Editable SVGs, fonts, wallpapers, translations and artwork metadata +vector-geometry/ Shared fill tessellation and vector boundary coverage +tools/vector-assets/ Host-only SVG compiler +tools/ Asset preparation, SDK/runtime setup, packaging and release feed +tests/ Desktop rendering and mocked HTTP smoke scripts +tv/autostart.sh Optional cold-boot launch hook +tv/home.sh / home.cjs Optional root-only native Home integration +dist/ Generated ARM IPK, checksum and Homebrew feed +.build/ Generated assets, SDK/runtime caches and rendering diagnostics +``` + +React, JSX, Vite, browser tests and web rendering are no longer part of the project. Node/pnpm +remain build/deployment tools for Sharp, the webOS CLI and formatting/linting. The installed app +is the Rust executable `luma`, with app ID `com.ecklf.luma`. + +## Toolchain and commands + +```sh +nix develop +pnpm install --frozen-lockfile +pnpm assets +pnpm dev +``` + +The flake supplies Rust 1.93, Cargo/rustfmt/Clippy, Node.js 24, pnpm 10.34.5, SDL2, pkg-config, +woff2, Python/fontTools, CMake and Git. Linux includes Mesa and Xvfb. Supported Nix hosts are +Apple Silicon macOS and ARM64/x86-64 Linux. Without Nix, supply those tools and Rust 1.88+, +including `armv7-unknown-linux-gnueabi` for TV builds. No browser installation is needed. + +| Command | Purpose | +| ------------------------- | ---------------------------------------------------------- | +| `pnpm dev [--options]` | Run the desktop preview | +| `pnpm assets [--4k]` | Prepare fonts, vector artwork and raster backgrounds | +| `pnpm screenshots` | Capture native Home, Apps, General and Inputs into `docs/` | +| `pnpm assets:vector` | Recompile bundled SVG icon/logo meshes | +| `pnpm assets:fonts` | Restore checksum-pinned Noto font source if needed | +| `pnpm sdk` | Download/relocate a pinned host webOS SDK | +| `pnpm runtime` | Prepare checksum-pinned SDL-webOS | +| `pnpm build` | Release ARM cross-build using `WEBOS_NDK` | +| `pnpm run package` | Assets, cross-build, IPK verification and Homebrew feed | +| `pnpm deploy --device tv` | Build/install/launch through an ares device profile | +| `pnpm check` | Oxlint and workspace Clippy, without executing tests | +| `pnpm format:check` | Rust and tooling/document formatting checks | +| `pnpm test` | Release-tooling and Rust workspace unit tests | +| `pnpm test:icons` | Mocked runtime icon reads and bundled vector checks | +| `pnpm test:smoke` | Desktop rendering, mocked weather and localization checks | +| `pnpm changeset` | Add a release note and requested version bump to a PR | +| `pnpm version:check` | Check synchronized app release versions | + +The TV target is ARMv7 **soft-float calling convention**, `gnueabi`, not `gnueabihf`. Codegen +uses the NDK's Cortex-A9/VFP baseline with NEON disabled for Rust code. Hardware floating point +is enabled without changing the calling convention; the pinned compiler emits unstable-feature +warnings for those target switches. `src/webos-compat.rs` implements `getauxval` for the SDK's +older libc by reading real kernel auxiliary values, used by Rust startup and TLS CPU detection. + +The NDK's SDL is a link-time stub with Wayland disabled. `tools/webos-runtime.mjs` instead pins +SDL-webOS `release-2.30.12-webos.5`, standard SDL ABI, with archive/library SHA-256 verification. +It is linked and packaged with runtime name `libSDL2-2.0.so.0`, loaded from `$ORIGIN/lib`. +Desktop pkg-config paths are isolated from the ARM build. `WEBOS_NATIVE_LIBS` can point to +explicitly selected ARM webOS shared libraries; SDL overrides must have a real webOS/Wayland +backend. Avoid copying the whole SDK library directory or the TV's graphics drivers/libc. + +## Controls and navigation + +### Documentation screenshots + +`pnpm screenshots` generates `docs/home.png`, `docs/apps.png`, `docs/settings.png` and +`docs/inputs.png`, linked by the README. It builds the desktop executable without the TV feature, +uses isolated temporary preferences, sample inventory and a fixed date/time, and captures actual +1920×1080 OpenGL framebuffers. Weather is unconfigured, so no real weather APIs are contacted. +It does not execute the test suites. A desktop display is required; on headless Linux use +`xvfb-run -a pnpm screenshots`. These images document the native design, not TV hardware behavior. + +### Input + +| Input | Action | +| ------------------------- | ------------------------------------------------------ | +| Arrows | Move focus, revealing remote-focused content | +| OK / Enter | Activate on release | +| Hold OK for 600 ms | Reorder Home favorite or open app options | +| OK or Back in move mode | Finish reordering without launching | +| Escape / Back / Backspace | Parent navigation, overlay dismissal or quit from Home | +| Menu / M / right-click | App options | +| Pointer / wheel | Hover, click, hold and scrolling | +| F5 | Refresh inventory | + +Backspace deletes while editing. Native Back uses SDL's webOS-specific keycode. Back dismisses +the keyboard first; Weather Location then returns to its parent Location row, HDMI choices to +that HDMI row, and the Background gallery to Background in the sidebar. Back from the sidebar +focuses the Settings tab, then returns Home. **System** in the sidebar groups **Refresh apps & inputs** +and the vector power-icon **Quit app** action separately from General preferences. +The **Clock** subpage contains **Clock format** (12-hour or 24-hour) and **Time format** (`HH:MM` +or `HH:MM:SS`, defaulting to `HH:MM`). Choosing a format returns to its row in Clock, and Back +from Clock restores its General row. With seconds shown, the header clock redraws once per second +instead of once per minute. Existing saved hour and seconds preferences are retained. +General has an **App names** subpage with independent **Show on Home** and **Show on Apps** toggles. +Home captions default to off and Apps captions default to on, stored independently in `show_home_names` +and `show_app_names`. The legacy `show_names` preference does not override these defaults. Hidden +captions do not reserve layout space. +The **Greeting** subpage contains **Name** and **Clear name**. Selecting Name opens the text keyboard +(the system keyboard on the TV) and the name is appended to the Home greeting ("Good evening, Ana."). +The keyboard starts empty; closing it without a name preserves the saved value. Clear name removes +it. Preferences are saved when the keyboard closes, including automatic system-keyboard dismissal. +Back from either subpage restores its General row. Input name removal also uses **Clear name**. +Choice checkmarks identify saved values, while the bright row fill identifies only the current focus. + +Home favorites form one horizontal strip, never a second row. Left/Right follows saved order, +including reorder mode, and reveals complete cards. Up returns to Home navigation; Down enters +the nearest input in the first row. The strip position survives page changes. Wheel movement over +favorites scrolls horizontally; elsewhere it scrolls vertically. Pointer hover/down never moves +the clicked control. Apps Up/Down follows the six-column grid order, including offscreen rows, +and only the first row's Up route reaches the category toolbar. Scrolling Apps content fades into +the existing wallpaper at the header/footer, with one alpha mask for surfaces, shadows, rings, +vector icons/logos and text. The wallpaper/header are unmasked; no blur texture is needed. + +TV editing uses the system keyboard through SDL-webOS. Desktop editing uses the custom panel. +System text events update app/location queries; Done/Back stops editing and restores focus. + +## Splash images + +`pnpm assets` renders one 1920×1080 PNG per background into `.build/assets/splash/` from the shaded +wallpapers. `tools/package.mjs` ships them under `splash/` plus `splash.png` (the default background), +which `appinfo.json` names as `splashBackground`, with `bgColor` set to the UI base colour so the +frame between the splash and the first draw is not black. On a normal TV launch and after successful +preference saves, the native platform worker passes the loaded background to the embedded +`tv/sync-splash.cjs` helper through Homebrew Channel's elevated `exec` service. It atomically updates +the installed `splash.png` and the matching enabled Home stage, skipping identical images. Desktop +and isolated `--preferences` / `--test-extra-apps` runs do not update launch splashes. The first launch +after installation or a package update can still show the packaged default until this sync completes; +later launches use the saved selection. With Home takeover enabled, `tv/home.cjs` +copies the selected background's splash into the staged Home app on every apply, reading the saved +preferences from the TV storage candidates in `storage.rs`. Production installations prefer internal +storage; developer installations prefer developer storage, with existing files taking precedence over +the legacy installation-local preferences. Reapplying an active Home mount also refreshes the splash. +`pnpm test:home` checks splash selection, runtime updates and mounted refreshes with a mocked filesystem +and no TV calls. Verify the system-rendered splash on a real TV by changing Background, quitting Luma, +and relaunching its normal tile; desktop checks cannot establish SAM/compositor image caching behavior. + +## Vector rendering + +`assets/ui-icons/` holds standalone SVGs exported from the original Lucide/control artwork, +including the supplied PlayStation/Apple TV marks. `assets/logos/` holds original bundled logos. +`tools/vector-assets/` parses SVGs with usvg, resolves stroke outlines and transforms, and uses +the shared lyon tessellator to emit `.lvec` geometry. The format contains positions, edge offsets, +vertex colors and coverage; holes, round strokes and the bundled gradients are preserved. +Gradient colors are adaptively sampled at build time. The TV uploads each mesh lazily once and +draws with a texture-free GLES2 shader. No SVG parser runs on the TV. The shared tessellator also +handles font outlines. Installed app PNG/JPEG/WebP artwork, wallpaper photos, tile-background +images and the webOS installation icon remain raster assets. See [assets.md](assets.md) for licenses. + +### Installed app artwork + +TV inventory retains each app's `folderPath`, `largeIcon` and `icon`. Visible cards resolve artwork +from that metadata and the installed `appinfo.json`, preferring large icons and falling back to +the regular icon or `icon.png`. Developer, store and system application directories are supported, +including absolute/`file://` paths and relative resource paths. Curated vector brand logos retain +their existing display treatment; other apps use their own installed artwork and an automatically +sampled card colour. Missing, corrupt or unsupported artwork uses the existing generic treatment. + +Discovery, bounded file reads, decoding and colour sampling run on a dedicated worker. If the +jailed app cannot read an asset directly, the native process calls Homebrew Channel's elevated +`exec` service with the embedded Node 8-compatible `tv/read-app-icon.cjs` reader. It returns bounded +image bytes without writing collected files or changing filesystem permissions. PNG, JPEG and WebP +are supported; runtime SVG parsing is not required. Source files are limited to 512 KiB and +2048×2048 pixels; decoded icons are at most 256×256 with a 16 MiB/256-entry CPU cache. GPU uploads +stay on the SDL thread and share the existing 48 MiB texture cache. Pending/unavailable icons do +not block input, and failures are cached until inventory is refreshed. + +Inventory also refreshes at startup and whenever the window becomes visible. Decoded icons are +keyed by app ID, version and icon paths, so those refreshes keep them and retry only previous +failures; an updated app has a new version and is read again. **Refresh apps** (or desktop F5) +invalidates all artwork, including previous failures; request tokens discard stale replies. Check newly installed/updated apps and both normal tile and Home-takeover +launches on a real TV to verify sandbox access. These paths are covered with mocked filesystem +and worker tests, not live-TV calls in automated checks. + +Dumped TV artwork, collected icon/colour maps, and their collection tools have been removed. +Desktop previews use curated vector logos or generic/category artwork for other demo apps. +Asset generation clears retired dumped-artwork outputs from older build caches, and packaging +continues rejecting those legacy files. Adding support for an installed TV app's icon does not +require collecting artwork or changing JSON. + +### Curated brand cards and the desktop demo + +`src/branding.rs` contains the exact app-ID allowlist for 38 curated brand and utility cards. Titles and +partial ID matches do not select vector overrides. In particular, Amazon Alexa retains its own +installed icon rather than receiving Prime Video branding. NOW / Now TV recognizes `now.tv` +and `nowtv`; WOW, Sky Q and the separate Hong Kong Now TV service are not treated as aliases. +Unknown regional IDs continue using their installed artwork. Verify additional aliases against +that TV's inventory before adding them to the allowlist. + +The aliases include developer fixtures and community webOS inventories, including +[FHEM's LGTV app IDs](https://github.com/fhem/LGTV_WebOS/blob/master/lib/FHEM/Devices/LGTV/LGTVWebOS.pm), +[a UK webOS inventory](https://github.com/chrispab/AutoHome/blob/master/items/CT_LGWebOS_TV.items), +and the [UK streaming catalogue](https://github.com/tjeffree/webOS-Streaming-Aggregator/blob/main/frontend/src/services/appCatalog.js). +These sources do not establish that every alias is available on every region/firmware combination. + +Every curated logo has an app in `demo_inventory()`, including all 27 expanded streaming/music +brands. Open them with `pnpm dev --script apps`, then scroll or search the Apps grid. A unit check +keeps the SVG directory, curated catalogue and demo inventory aligned. `pnpm test:brands` renders +every card through the actual desktop renderer and writes `.build/brands-gallery.png` for visual +review. Like the other smoke scripts, it uses isolated preferences and no TV or real weather API. +The gallery also runs as part of `pnpm test:smoke`. Asset source/licence details are in [assets.md](assets.md). + +Live TV (`com.webos.app.livetv`) and Web Browser (`com.webos.app.browser`) also have dedicated +SVG-backed demo cards: a blue/cyan TV display with a live indicator, and a matching globe with a +navigation pointer. Both are original Luma vector artwork. The existing card caption remains available when +names are visible. `Brand::artwork_size()` sets optical sizing per brand, keeping heavy wordmarks +such as Hulu, Joyn and Plex closer to the visual weight of Apple TV, YouTube, Das Erste and ARTE. + +Text reads the bundled TTF's advances, kerning and line metrics lazily. Only needed glyph outlines +are tessellated in font-em coordinates. Complete text runs are cached in static GPU buffers, +independent of position/color/drawable density, avoiding a draw call per letter. CJK fonts open +only when needed; there are no glyph bitmap atlases or full-font mesh exports. + +The window requests 4× MSAA, with 2×/single-sample fallbacks if EGL rejects a configuration. +Multisampled text draws intact glyph fills without inward offsets/fringe triangles, which can +cross on very thin small-text strokes. Single-sample targets use a narrower half-pixel boundary +fringe. Glyph origins/baselines are pixel-aligned per drawable axis. There is no LCD subpixel +rendering, font hinting or complex-script shaping. Inspect `multisample_samples` and +`font_antialiasing`: offscreen captures can be single-sample while the actual window uses MSAA. + +Glyph geometry has an 8 MiB/4096-entry CPU cache target and text runs a 32 MiB/512-entry GPU +target. Eviction preserves the current frame, which can temporarily exceed those targets. +Raster artwork has a 48 MiB cache budget. Diagnostics include geometry bytes, triangles, draw +calls and missing/failed artwork/glyphs. Idle screens draw on demand; VSync paces animation. + +## Resolution and profiling + +### Rendering requests and captures + +The shipping default requests 1920×1080 and retains a 1920×1080 logical layout. Drawable-aware +vector coverage follows the actual SDL framebuffer. `--resolution 4k` is an **experimental** +native surface request, not a guaranteed panel-output mode. The TV compositor can constrain or +rescale it. A 4K screenshot or drawable alone does not prove 1:1 panel presentation. + +```sh +pnpm dev --resolution 4k --profile-frames 180 --diagnostics .build/profile.json +pnpm dev --script 'home,down' --frames 90 --capture-scale 2 --screenshot .build/home-4k.png +``` + +`--resolution` also accepts `1080p` or `WIDTHxHEIGHT`. Explicit TV requests use a borderless +window, avoiding SDL desktop-fullscreen overriding the dimensions. `--capture-scale 2` uses a +separate 3840×2160 offscreen target and leaves the actual window unchanged. Font/vector geometry +uses that target's physical coverage rather than enlarging a 1080p image. Optional `@2x` wallpaper +images need both `pnpm assets --4k` and `node tools/package.mjs --4k`; default packages exclude +them even if they remain in an old cache. Source photographic detail still limits sharpness. + +### 4K TV investigation + +Manual testing on 2026-10-23 used an **LG OLED55C57LA (C5), webOS 26, chip `O22A3`, driver +`lg1kdrm`**. A contributor reported similar corruption on a C3 in +[#25](https://github.com/ecklf/lg-luma/issues/25). Usable native 4K UI presentation has not been +established on these tested TVs. + +The C5's default EGLFS JSON reported `1920x1080`, and the active DRM state independently confirmed +a 1080p compositor framebuffer and output mode. The connector advertised `3840x2160`, and +`/proc/lg/fbdev/drm-helper/check_mode` reported `3840x2160: ok` (`7680x4320: err`). FB0 reported +`cfg.max_xres = 1920`, `cfg.max_yres = 1080` and virtual size `1920,2160`, consistent with two +stacked 1080p buffers rather than a 4K framebuffer. + +The startup script uses Weston-style arguments and a `weston.ini` with `backend=drm-backend.so` +and `override-output-from-env=true`. Its product environment also generates the EGLFS JSON. +Inspect the actual startup path and DRM state; the JSON alone does not establish which output +configuration is active. + +A temporary compositor-level test bind-mounted patched copies of `surface-manager.sh` and +`eglfs_starfish.env`, requesting matching 3840×2160 compositor and primary output geometry, then +restarted `surface-manager-daemon.service`. Systemd scheduled restoration after 90 seconds. + +| Observation | Default | Forced 4K test | +| ---------------------------- | ------------------ | ------------------------------- | +| Compositor framebuffer | 1920×1080 | 3840×2160 | +| Format | ABGR8888 with AFBC | ABGR8888 with AFBC | +| Pitch | 7680 bytes | 15360 bytes | +| Plane source and destination | 1920×1080 | 3840×2160 | +| Active DRM mode | 1920×1080 at 60 Hz | 3840×2160 at 60 Hz | +| Visible picture | Normal | Flickering lines and corruption | + +Restoring the original configuration returned the picture to normal. This was a system compositor +experiment, not a supported Luma setting or installation procedure. + +**Mode acceptance, a 4K drawable or screenshot, and even matching 4K framebuffer/plane/CRTC +dimensions do not prove correct panel presentation.** Check the visible output and use a +pixel-resolution test pattern before claiming 1:1 presentation. The corruption's exact cause is +unresolved: FB0's configured maximum and compatibility metadata alone do not establish a physical +hardware limit or explain the failure. These results do not prove that 4K graphics are impossible +on every LG TV; they establish that the tested override does not produce a usable picture on this C5. +An app-side resolution request alone does not resolve that display-path failure. + +For read-only inspection in the TV's root SSH shell (paths depend on firmware): + +```sh +cat /sys/kernel/debug/dri/0/state +cat /proc/lg/fbdev/status +cat /proc/lg/fbdev/drm-helper/status +cat /proc/lg/fbdev/drm-helper/check_mode +``` + +**4K video playback is a separate capability.** LG's +[app resolution specifications](https://webostv.developer.lge.com/develop/specifications/app-resolution) +distinguish 1080p graphics from 4K video on UHD TVs. Native media players can route decoded video +through a separate video plane while drawing their UI at 1080p. For example, +[PlxNative documents its default 1080p UI drawable](https://github.com/GLinnik21/plx-native/blob/13bed25004df4c89f8bcba9558b571b8ed962bd0/rust-modules/base/src/surface.rs#L10-L25) +alongside 4K video support. That media path does not demonstrate a usable 4K OpenGL graphics path +for Luma; bypassing the browser does not by itself reconfigure LG's graphics output pipeline. + +### Frame profiling + +`--profile-frames N` forces redraws and measures swap-to-swap intervals after ten warmup +presentations. Hidden windows pause profiling. Capture stalls are excluded. CPU submission is +not a GPU timestamp, and `--frames` counts event-loop iterations, not presentation rate. +Inspect `window`, `drawable`, `drawable_is_4k`, `last_presented`, `capture` and `profile` in JSON. +Do not infer 60 fps from a successful VSync request; measure actual TV pacing and transitions. + +## TV startup diagnostics + +SAM passes launch metadata as a JSON object in `argv[1]`. The CLI skips that object while leaving +the original process arguments available to SDL/libhelpers. To expose the actual developer-mode +sandbox's stdout/stderr, close Luma and run in the TV's root SSH shell: + +```sh +APP=/media/developer/apps/usr/palm/applications/com.ecklf.luma +XDG_RUNTIME_DIR=/tmp/xdg RUST_BACKTRACE=1 /usr/bin/jailer \ + -t native_devmode -i com.ecklf.luma -p "$APP" \ + "$APP/luma" '{"@system_native_app":true,"nid":"com.ecklf.luma"}' +``` + +Startup prints accepted launch parameters, SDL driver, actual sample count, cumulative stage +timings, first-frame latency and vector-text cache size. Timings begin inside the executable and +exclude SAM/jailer process-launch overhead. The IPK is unpacked at install, not every launch. +A previous vector-font startup on the development TV presented its first frame in 399 ms; +new rendering paths still need their own hardware comparisons. SDL helper `getAppInfo` or +`getSystemSettings` registration calls can report permission warnings; they have not blocked +the verified interface/inventory runs. Normal launch/relaunch behavior should be checked too. + +The native adapter registers LS2 connections from the executable and calls `LSCallOneReply`. +Anonymous names avoid collisions with SDL's registration; the hub still applies the executable's +role. `luna-send-pub -a` app impersonation is rejected on the development webOS 26 TV and is not +used. Native requests run on one worker's private GLib context with a 6.5-second budget and 2 MiB +reply limit. Errors include URI and the bus refusal. Fixed inventory commands run via elevated +Homebrew Channel `exec`, parsing complete multi-line `luna-send -n 1` JSON replies. Input-read +failures preserve the last valid inputs. No failed inventory read substitutes demo apps on TV. + +### Optional native Home integration + +`tv/home.sh` and `tv/home.cjs` are packaged beside `luma`. They are a root-only, explicit integration +for webOS 10/25 and 11/26. `enable` stages a native manifest with ID `com.webos.app.home` and +symlinks to the installed executable, libraries and assets under `/var/lib/luma-home/app`, then +bind-mounts it over `/usr/palm/applications/com.webos.app.home`. SAM rereads that one app through +`blockSystemApp` followed by `releaseBlockSystemApp`, including `forceBlockSystemApp: false` when +the TV schema requires it. The script verifies the reported manifest before stopping the previous +Home process. It attempts to restore LG Home if applying the mount or refreshing SAM fails. + +Before staging, `enable` closes the normal Luma instance with `closeByAppId` and waits up to five +seconds for it to leave the running list. The TV's `close` endpoint identifies a process, so passing +the package ID there can return `no app matched by pid`. If closing reports an error, the script +rechecks the running list and proceeds only if the normal instance has already exited. + +Root-shell calls use `luna-send -n 1` on both firmware versions, with bounded stdout/stderr +capture and operation-specific diagnostics for empty or invalid replies. `-t 1` is timing mode: +it logs the payload to stderr and writes timing summaries to stdout, not a JSON response. + +The `enable` shell entry point prints the change summary and requires a `y`/`n` confirmation +before creating the state directory, acquiring the lock or invoking the installer. Only `y`/`Y` +proceeds. `n`/`N` or end-of-input cancels, and other input repeats the prompt. The boot hook and +`apply` remain non-interactive, and `disable`/`restore` remain available without confirmation. + +The native CLI accepts SAM's Home `nid` and gives SDL the matching Home surface identity. The +executable itself stays at its installed path for LS2 authentication, `$ORIGIN/lib`, asset discovery +and preference selection. Opening Luma's normal tile while the Home mount is active redirects to +the Home instance. The scripts do not create a browser wrapper or change the package's app ID. + +`64-luma-home` reapplies the mount from a detached boot hook. The existing TV Home Auto Launch +and Quick Start+ settings determine whether power-on opens Home. Until the hook applies, LG Home +can still appear. This integration does not change those settings or install a remote-key filter. +`disable` (also `restore`) removes the mount, refreshes LG's manifest, stops the native Home +instance and removes the boot hook. The copied restore scripts remain in `/var/lib/luma-home` +even if the installed package is removed. Disable the standalone `65-luma` cold-boot hook before +enabling this integration. + +Build and script checks cannot establish native Home compatibility. Manually verify Home, Back +out of the last app, standby wake with the TV's chosen Quick Start+ setting, a full cold boot, +saved preferences and restoring LG Home. The broader webOS 6+ app compatibility statement does +not establish Home-replacement support on older firmware. + +## Manual extra-app preview + +```sh +pnpm dev --test-extra-apps 120 +``` + +On the TV, add `--test-extra-apps 120` after the JSON argument in the jailed command above. +This explicitly appends up to 300 labeled non-launchable cards to a successful real inventory, +using its apps as artwork/category templates. It uses `scroll-preview-preferences.json`, +initialized from normal choices once. Favoriting test cards exercises long Home strips without +changing normal favorites. Ctrl+C then a normal launch restores real inventory/preferences. +The placeholder cards are never a fallback for an inventory failure. + +## Storage, weather and languages + +TV preferences live in an app-owned persistent directory outside the installed executable's +payload. Startup probes `/media/developer//`, `/media/developer/temp//` and +`/media/internal/./`, selecting a directory the jailed app can actually create and write. +Production `/media/cryptofs` installations prefer internal storage. `/media/developer/temp` is +on the persistent developer partition, not the reboot-cleared `/tmp`. No settings are placed +in `/tmp` and no system/app-payload permissions are widened. Existing JSON preferences and a +valid weather cache from beside this installation's old executable are imported once, without +overwriting the chosen destination. Invalid existing preferences still report an error. +Startup prints `Luma persistent storage:` with the chosen path. Desktop preview retains the legacy +`$XDG_CONFIG_HOME/luma-native/preferences.json` path, or `~/.config/luma-native/preferences.json`, +so existing preview choices survive flattening. `--preferences PATH` isolates a manual run. +Invalid preference files report an error rather than being overwritten. Writes use atomic +temporary-file renames; use one instance per preferences file. Browser local storage is not +migrated into native JSON preferences. + +Weather Location searches cities/postcodes only on explicit Search. Forecasts run when Home is +visible, the widget enabled and a city configured. ureq/rustls uses embedded certificate roots, +an eight-second timeout, no redirects/proxy and 256 KiB reply limits. Search/forecast/cache writes +have separate workers and generation IDs, avoiding stale responses overwriting current state. +Fresh Celsius readings are reused for 15 minutes; valid saved data lasts up to six hours and +failure retries after five minutes. Unit changes convert unrounded cached Celsius immediately. +Removing a city clears its cache. The `.weather.json` sidecar uses Unix-seconds timestamps. +Mock servers accept only literal loopback HTTP origins and are refused in TV builds. + +`assets/messages.json` supplies English/German/Spanish/Simplified Chinese, including locale formatting +and weather labels. App/device names and saved city names remain real data. No geolocation, +runtime font/translation services or telemetry are used. Weather attribution: Open-Meteo, +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). + +## Compatibility + +Luma's native startup, rendering and app/input discovery have been verified on rooted webOS 26. +External testing confirms compatibility with webOS 6 and newer. On webOS 26 use elevated Homebrew Channel +0.8.0-rc1+ for `lunabus.query`. Root SSH execution does not establish the normal jailed caller +permissions; check TV-menu startup, launching/reopening and remote/pointer behavior manually. + +[webOS Homebrew Can I Use](https://www.webosbrew.org/develop/caniuse) lists firmware library +versions. Its [library guide](https://www.webosbrew.org/develop/guides/library-version) maps +internal releases to marketing names: 7/22, 8/23, 9/24, 10/25, 11/26. SDL-webOS's newer +[`2.30.12-webos.6`](https://github.com/webosbrew/SDL-webOS/releases/tag/release-2.30.12-webos.6) +reports hotplug verification on webOS 3.4, 4 and 10. That is subsystem evidence for a newer +prerelease than the pinned `.5` library, not a full Luma compatibility guarantee. + +## Checks and CI + +Rust behavior tests cover navigation, preferences, localization, rendering geometry and weather +state without a TV. `tests/` scripts exercise actual desktop framebuffers and mock HTTP responses +for navigation, persistence, pointer input, translations/CJK, weather limits/cache/races and +profiling. They contact no live weather APIs or television. On Linux: + +```sh +pnpm format:check +pnpm check +pnpm test +pnpm assets +xvfb-run -a pnpm test:smoke +``` + +CI uses this native-only flow, then cross-links with a pinned Linux SDK, inspects/normalizes the +actual ARM IPK and generates checksums/feed. It uploads release/rendering artifacts on PRs, +main-branch pushes and manual runs. `pnpm check` also validates synchronized release versions, +and `pnpm test:release` covers version synchronization and the publish gate, including the real +Changesets version command in an isolated fixture. Host CI cannot prove hardware compatibility, +small-text quality or presentation frame rate. + +## Releases + +Add a changeset to feature/fix PRs that should be included in the next app release: + +```sh +pnpm changeset +``` + +Select `lg-luma`, choose patch/minor/major and describe the change. Commit the generated file +under `.changeset/` with the PR. Documentation-only and maintenance PRs that do not need a release +can omit it. Keep the existing app version in ordinary PRs. + +`.github/workflows/changeset-status.yml` adds a Changesets status check and maintains one comment +per PR, including fork PRs. It reports the requested release types or notes that a PR without a +changeset will not request an app version bump. For code or behavior changes, add a changeset +using semantic versioning: **patch** for backward-compatible fixes, **minor** for backward-compatible +features, or **major** for breaking changes. The comment is informational, so documentation-only +and maintenance PRs can still merge without a release changeset. It updates when a PR is opened, +reopened, edited, marked ready or receives new commits, and skips the generated Version Packages +PR. The workflow becomes active once it is merged into the PR's base branch. It uses +`pull_request_target` with a read-only status job and a separate comment-writing job, without +installing dependencies or running code from the PR. + +After changesets merge into `main`, `.github/workflows/release-pr.yml` creates or updates the +**Version Packages** PR on `changeset-release/main`. Changesets combines the pending notes using +the highest requested bump. The custom `pnpm version:release` command updates `package.json` +and `CHANGELOG.md`, then synchronizes `appinfo.json`, the root `Cargo.toml`, the `luma` entry in +`Cargo.lock`, and the versioned IPK examples in `readme.md`. Dependency pins and supporting Rust +crate versions are preserved. The package stays private and is never published to npm. + +The generated PR receives the same CI build as ordinary PRs. Since PRs created with GitHub's +built-in token do not trigger `pull_request` workflows, the release workflow explicitly dispatches +`ci.yml` for the version branch. Enable **Settings → Actions → General → Allow GitHub Actions to +create and approve pull requests** in the repository. No additional token or npm secret is needed. + +Review and merge the version PR when ready to release. Only a main-branch push associated with +that merged, same-repository PR and a numeric version increase can enter the publish job. It must +also pass the existing checks, desktop smoke tests and native packaging build. Ordinary merges, +unmerged PRs and manual CI runs do not publish. To retry a failed publication, rerun the original +CI run for the merged version PR's main-branch commit. + +Publication retains the existing guards: an already-published version is skipped, a draft can be +resumed, and a tag pointing at another commit requires a new app version. `pnpm version:check` +checks synchronization without writing files. `pnpm version:sync` repairs metadata from the +current `package.json` version without calculating a bump. `pnpm version:release` is reserved for +the automated version PR. + +Homebrew Channel's details page reads `packages[].shortDescription` (or `fullDescriptionUrl`), +not `manifest.appDescription`. The feed generator writes the root app metadata description into +`shortDescription` and `manifest.appDescription`. It also copies `assets/homebrew-description.html` +to `dist/description.html` and sets the package's `fullDescriptionUrl` to that version's release +asset. Homebrew Channel prefers this HTML description when the URL is present. CI uploads the +HTML alongside the feed and IPK, so the feed and its description must be published together. + +Key modules: `state.rs` focus/preferences, `view.rs` geometry, `renderer.rs` GPU caches/draws, +`font.rs` lazy metrics/outlines, `vector.rs` GPU mesh upload, `platform.rs` worker/inventory, +`luna.rs` LS2, `weather*.rs` scheduling/HTTPS/cache, `storage.rs` atomic JSON, and `main.rs` SDL loop. diff --git a/docs/apps.png b/docs/apps.png new file mode 100644 index 0000000..778c5ca Binary files /dev/null and b/docs/apps.png differ diff --git a/docs/home.png b/docs/home.png new file mode 100644 index 0000000..fdb79ce Binary files /dev/null and b/docs/home.png differ diff --git a/docs/inputs.png b/docs/inputs.png new file mode 100644 index 0000000..33fd409 Binary files /dev/null and b/docs/inputs.png differ diff --git a/docs/settings.png b/docs/settings.png new file mode 100644 index 0000000..4c4a55d Binary files /dev/null and b/docs/settings.png differ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3583f3a --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1792506237, + "narHash": "sha256-Q/8KLB6jNfpaUN1HAedE/u/Et9feE/zYSsutBLKrCFk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5adc50831c259f87faeb09ddf930d43597197473", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1792567368, + "narHash": "sha256-mrM+zFDRO1pI85t2SEq5R739BIq7Nv5XkFtVZK9nkZM=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "83dd732f2428827f35d997deae108a2ced5125f4", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..69460ff --- /dev/null +++ b/flake.nix @@ -0,0 +1,86 @@ +{ + description = "Luma Rust/SDL2 development and packaging environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { nixpkgs, rust-overlay, ... }: + let + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; + forEachSystem = + f: + nixpkgs.lib.genAttrs systems ( + system: + f ( + import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + } + ) + ); + project = builtins.fromJSON (builtins.readFile ./package.json); + pnpmVersion = nixpkgs.lib.removePrefix "pnpm@" project.packageManager; + in + { + devShells = forEachSystem ( + pkgs: + let + # Match the native CI toolchain and include the TV's soft-float Rust std. + rust = pkgs.rust-bin.stable."1.93.0".minimal.override { + extensions = [ + "clippy" + "rustfmt" + ]; + targets = [ "armv7-unknown-linux-gnueabi" ]; + }; + pnpm = + assert pkgs.lib.assertMsg ( + pkgs.pnpm_10.version == pnpmVersion + ) "The flake's pnpm must match package.json; update flake.lock and packageManager together."; + pkgs.pnpm_10; + in + { + default = pkgs.mkShell ({ + name = "luma"; + packages = [ + pkgs.nodejs_24 + pnpm + rust + pkgs.pkg-config + pkgs.SDL2 + pkgs.woff2 + (pkgs.python3.withPackages (python: [ python.fonttools ])) + pkgs.cmake + pkgs.git + ] + ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ + pkgs.libGL + pkgs.mesa + pkgs.xvfb-run + ]; + }); + } + ); + + formatter = forEachSystem ( + pkgs: + pkgs.writeShellApplication { + name = "luma-nixfmt"; + runtimeInputs = [ pkgs.nixfmt ]; + text = '' + exec nixfmt "$@" flake.nix + ''; + } + ); + }; +} diff --git a/ha-home/config.json b/ha-home/config.json new file mode 100644 index 0000000..61abad7 --- /dev/null +++ b/ha-home/config.json @@ -0,0 +1,148 @@ +{ + "version": 1, + "ha": { + "url": "wss://ha.xtrm-lab.org/api/websocket", + "token": "" + }, + "weather": { + "entity_id": "weather.forecast_home" + }, + "vacuum": { + "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", + "cleaning_mode_entity": "select.living_room_roborock_s7_pro_ultra_cleaning_mode", + "rooms": { + "16": "Kitchen", + "17": "Bedroom", + "18": "Dining Room", + "19": "Corridor", + "20": "Bathroom", + "21": "Living Room", + "22": "Master Bedroom", + "23": "Corridor 2", + "24": "Bedroom 2" + } + }, + "rooms": [ + { + "id": "living_room", + "name": "Living Room", + "background": "living-room.jpg", + "sensors": [ + "sensor.living_room_thermostat_temperature", + "sensor.living_room_thermostat_humidity" + ], + "cards": [ + { "type": "climate", "entity_id": "climate.living_room_thermostat", "name": "Thermostat" }, + { "type": "climate", "entity_id": "climate.living_room_radiator", "name": "Radiator" }, + { "type": "range", "entity_id": "light.living_room_light", "name": "Ceiling Light" }, + { "type": "range", "entity_id": "light.tv_lights", "name": "TV Lights" }, + { "type": "range", "entity_id": "light.rgbic_strip_lights_z1", "name": "LED Strip 1" }, + { "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" }, + { "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" }, + { "type": "toggle", "entity_id": "cover.living_room_curtain", "name": "Curtain" }, + { "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" } + ] + }, + { + "id": "kitchen", + "name": "Kitchen", + "background": "kitchen.jpg", + "sensors": [ + "sensor.kitchen_termostat_temperature", + "sensor.kitchen_termostat_humidity" + ], + "cards": [ + { "type": "climate", "entity_id": "climate.kitchen_termostat", "name": "Thermostat" }, + { "type": "toggle", "entity_id": "switch.dishwasher_power", "name": "Dishwasher" } + ] + }, + { + "id": "bedroom", + "name": "Dad's Bedroom", + "background": "bedroom.jpg", + "sensors": [], + "cards": [ + { "type": "climate", "entity_id": "climate.main_bedroom_radiator", "name": "Radiator" }, + { "type": "toggle", "entity_id": "light.bedroom_ceiling_light", "name": "Ceiling" }, + { "type": "toggle", "entity_id": "light.bedroom_desk_lights", "name": "Desk" }, + { "type": "range", "entity_id": "light.bedroom_led_strip", "name": "LED Strip" } + ] + }, + { + "id": "dining_room", + "name": "Dining Room", + "background": "dining-room.jpg", + "sensors": [], + "cards": [ + { "type": "range", "entity_id": "light.dining_room_light", "name": "Light" } + ] + }, + { + "id": "entrance", + "name": "Entrance", + "background": "entrance.jpg", + "sensors": [], + "cards": [ + { "type": "toggle", "entity_id": "light.entrance_light_1", "name": "Light" }, + { "type": "toggle", "entity_id": "light.entrance_mirror_light", "name": "Mirror" } + ] + }, + { + "id": "dari_bedroom", + "name": "Dari's Bedroom", + "background": "dari-bedroom.jpg", + "sensors": [], + "cards": [ + { "type": "climate", "entity_id": "climate.girls_bedroom_radiator", "name": "Radiator" } + ] + }, + { + "id": "teo_s_bedroom", + "name": "Teo's Bedroom", + "background": "teo-bedroom.jpg", + "sensors": [], + "cards": [ + { "type": "toggle", "entity_id": "light.boys_smartlight", "name": "Light" } + ] + }, + { + "id": "main_bathroom", + "name": "Main Bathroom", + "background": "bathroom.jpg", + "sensors": [], + "cards": [ + { "type": "toggle", "entity_id": "light.bathroom_light_1", "name": "Light 1" }, + { "type": "toggle", "entity_id": "light.bathroom_light_2", "name": "Light 2" } + ] + }, + { + "id": "kids_bathroom", + "name": "Kids Bathroom", + "background": "kids-bathroom.jpg", + "sensors": [], + "cards": [ + { "type": "toggle", "entity_id": "light.kids_bathroom_light_1", "name": "Light 1" }, + { "type": "toggle", "entity_id": "light.kids_bathroom_light_2", "name": "Light 2" }, + { "type": "toggle", "entity_id": "switch.washing_machine_power", "name": "Washing Machine" }, + { "type": "toggle", "entity_id": "switch.dryer_power", "name": "Dryer" } + ] + }, + { + "id": "scenes", + "name": "Scenes", + "background": "scenes.jpg", + "sensors": [], + "cards": [ + { "type": "action", "entity_id": "scene.tv_mode", "name": "TV Mode" }, + { "type": "action", "entity_id": "scene.living_room_100", "name": "Living Room 100%" }, + { "type": "action", "entity_id": "scene.living_room_lights_10", "name": "Living Room 10%" }, + { "type": "action", "entity_id": "scene.desk_mac", "name": "Desk: Mac" }, + { "type": "action", "entity_id": "scene.desk_pc", "name": "Desk: PC" }, + { "type": "action", "entity_id": "scene.curtains_close", "name": "Close Curtains" }, + { "type": "action", "entity_id": "script.tv_ambient_on", "name": "TV Ambient On" }, + { "type": "action", "entity_id": "script.tv_ambient_off", "name": "TV Ambient Off" }, + { "type": "action", "entity_id": "script.living_dining_all_off", "name": "All Lights Off" } + ] + } + ] +} diff --git a/ha-home/config.schema.json b/ha-home/config.schema.json new file mode 100644 index 0000000..a022e50 --- /dev/null +++ b/ha-home/config.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Luma Home Tab Configuration", + "description": "Configuration for the Home Assistant tab in the Luma launcher on webOS TV", + "type": "object", + "required": ["version", "ha", "rooms"], + "properties": { + "version": { + "const": 1, + "description": "Schema version" + }, + "ha": { + "type": "object", + "required": ["url", "token"], + "properties": { + "url": { + "type": "string", + "description": "Home Assistant WebSocket URL (wss://...)" + }, + "token": { + "type": "string", + "description": "Long-lived access token" + } + } + }, + "weather": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "Weather entity to display (e.g. weather.forecast_home)" + } + } + }, + "vacuum": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "Vacuum entity ID" + }, + "cleaning_mode_entity": { + "type": "string", + "description": "Select entity for cleaning mode (vacuum/mop/vac_and_mop)" + }, + "rooms": { + "type": "object", + "description": "Map of segment ID to display name", + "additionalProperties": { "type": "string" } + } + } + }, + "rooms": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "cards"], + "properties": { + "id": { + "type": "string", + "description": "HA area_id or custom slug" + }, + "name": { + "type": "string", + "description": "Display name" + }, + "background": { + "type": "string", + "description": "Background image filename (served from config dir)" + }, + "sensors": { + "type": "array", + "description": "Sensor entity IDs shown as badges in room header", + "items": { "type": "string" } + }, + "cards": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "entity_id"], + "properties": { + "type": { + "enum": ["toggle", "range", "climate", "action", "vacuum"], + "description": "Card type" + }, + "entity_id": { + "type": "string", + "description": "HA entity ID" + }, + "name": { + "type": "string", + "description": "Override display name (defaults to HA friendly_name)" + }, + "icon": { + "type": "string", + "description": "Override icon name" + } + } + } + } + } + } + } + } +} diff --git a/license b/license new file mode 100644 index 0000000..bac82a9 --- /dev/null +++ b/license @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Luma contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json new file mode 100644 index 0000000..7836b98 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "lg-luma", + "version": "2.3.1", + "private": true, + "description": "A modern, remote-first webOS launcher.", + "license": "MIT", + "type": "module", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22", + "pnpm": ">=10 <11" + }, + "scripts": { + "dev": "cargo run --locked --", + "build": "sh tools/build-webos.sh", + "package": "pnpm assets && pnpm build && node tools/package.mjs && node tools/repository.mjs", + "deploy": "pnpm run package && node tools/deploy.mjs", + "test": "pnpm test:release && pnpm test:home && pnpm test:icons && cargo test --locked --workspace --all-features", + "test:release": "node --test tests/release.test.mjs", + "test:home": "node --test tests/home-splash.test.mjs tests/sync-splash.test.mjs", + "test:icons": "node --test tests/read-app-icon.test.mjs tests/brand-assets.test.mjs", + "test:smoke": "node tests/smoke.mjs && node tests/brands-smoke.mjs && node tests/weather-smoke.mjs && node tests/translations-smoke.mjs", + "test:brands": "node tests/brands-smoke.mjs", + "check": "pnpm version:check && pnpm lint && cargo clippy --locked --workspace --all-targets --all-features -- -D warnings", + "lint": "oxlint --deny-warnings .", + "lint:fix": "oxlint --fix --deny-warnings .", + "format": "cargo fmt --all && oxfmt --write .", + "format:check": "cargo fmt --all -- --check && oxfmt --check .", + "assets": "node tools/assets.mjs", + "screenshots": "pnpm assets && node tools/screenshots.mjs", + "assets:vector": "node tools/vector-assets.mjs", + "assets:brands": "node tools/brand-assets.mjs", + "assets:fonts": "node tools/font-assets.mjs", + "runtime": "node tools/webos-runtime.mjs", + "sdk": "node tools/setup-webos-sdk.mjs", + "test:weather": "node tests/weather-smoke.mjs", + "test:translations": "node tests/translations-smoke.mjs", + "changeset": "changeset", + "version:release": "changeset version && pnpm version:sync && oxfmt --write CHANGELOG.md", + "version:sync": "node tools/sync-version.mjs", + "version:check": "node tools/sync-version.mjs --check" + }, + "devDependencies": { + "@changesets/cli": "3.0.3", + "@webos-tools/cli": "3.2.6", + "oxfmt": "0.73.0", + "oxlint": "1.88.0", + "sharp": "0.35.5" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2e68e82 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2951 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@changesets/cli': + specifier: 3.0.3 + version: 3.0.3 + '@webos-tools/cli': + specifier: 3.2.6 + version: 3.2.6(@types/node@24.19.2) + oxfmt: + specifier: 0.73.0 + version: 0.73.0 + oxlint: + specifier: 1.88.0 + version: 1.88.0 + sharp: + specifier: 0.35.5 + version: 0.35.5(@types/node@24.19.2) + +packages: + + '@changesets/apply-release-plan@8.1.1': + resolution: {integrity: sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/assemble-release-plan@7.0.0': + resolution: {integrity: sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/changelog-git@1.0.0': + resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/cli@3.0.3': + resolution: {integrity: sha512-L1i67bMfkK6iC/kDejuNO4pFgTRcWIObsD6Qof2woP1I4M8tj17DGlcpRaTzTv1DYSP5EMwWdEtR8X0Pj5Jg3Q==} + engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'} + hasBin: true + + '@changesets/config@4.0.1': + resolution: {integrity: sha512-PxpxSQLQSLDjceAgRq+4FWlU+HflDIw03mmPVipxoQx9xrXFUjmqTy3SE+n/2t+DqPdpPiLWVr5NRiLZvjyLwg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/errors@1.0.0': + resolution: {integrity: sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/format@0.1.2': + resolution: {integrity: sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/get-dependents-graph@3.0.0': + resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/git@4.0.1': + resolution: {integrity: sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/parse@1.0.0': + resolution: {integrity: sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/pre@3.0.0': + resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/read@1.0.1': + resolution: {integrity: sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/should-skip-package@1.0.0': + resolution: {integrity: sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/types@7.0.0': + resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/write@1.0.1': + resolution: {integrity: sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@manypkg/find-root@3.1.0': + resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} + engines: {node: '>=20.0.0'} + + '@manypkg/get-packages@3.1.0': + resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} + engines: {node: '>=20.0.0'} + + '@manypkg/tools@2.1.2': + resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} + engines: {node: '>=20.0.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.73.0': + resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.73.0': + resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.73.0': + resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.73.0': + resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.73.0': + resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.73.0': + resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.88.0': + resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.88.0': + resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.88.0': + resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.88.0': + resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.88.0': + resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.88.0': + resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.88.0': + resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.88.0': + resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.88.0': + resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.88.0': + resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pnpm/deps.graph-sequencer@1100.0.1': + resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} + engines: {node: '>=22.13'} + + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@webos-tools/cli@3.2.6': + resolution: {integrity: sha512-VkCV8oBycgd0UTT3n8xOCUKjzIFaJM5MzJWr9BqmHfplUemtMsTBx+1GNOyxio6Ky1mj/iyqAUx4VcUy2nQu3Q==} + engines: {node: '>=20.0.0'} + os: [darwin, linux, win32] + hasBin: true + + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + ar-async@0.1.4: + resolution: {integrity: sha512-iuFSHl96gQAStEPK457rAqVX0n5CVWrnE7SPdc/yS0ixkQXRd6h9W+8TJ1IA0sqgLolIO3oudpG8jl033GAiNg==} + + are-we-there-yet@4.0.2: + resolution: {integrity: sha512-ncSWAawFhKMJDTdoAeOV+jyW1VCMj5QIAwULIBV0SSR7B/RLPPEQiknKcg/RIIZlUQrxELpsxMiTUoAQ4sIUyg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + deprecated: This package is no longer supported. + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@7.1.0: + resolution: {integrity: sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA==} + engines: {node: '>=20.19.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csv-writer@1.6.0: + resolution: {integrity: sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + elfy@1.0.0: + resolution: {integrity: sha512-4Kp3AA94jC085IJox+qnvrZ3PudqTi4gQNvIoTZfJJ9IqkRuCoqP60vCVYlIg00c5aYusi5Wjh2bf0cHYt+6gQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + endian-reader@0.3.0: + resolution: {integrity: sha512-zPlHN59VLEjeJtpEU41ti/i7ZvTbwclvUN2M8anCsI3tOC/3mq6WNTJEKi49A5eLGvDkA0975LZb67Xwp7u4xQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + engines: {node: '>=14.14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gauge@5.0.2: + resolution: {integrity: sha512-pMaFftXPtiGIHCJHdcUUx9Rby/rFT/Kkt3fIIGCs+9PMDIljSyRiqraTlxNtBReJRDfUefpa263RQ3vnp5G/LQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + deprecated: This package is no longer supported. + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-id@4.2.2: + resolution: {integrity: sha512-Z+8unD6em/MJwyrkWQ2M3wygRGI1+MztbutiNwk1DCuEPXOZRWGjtcMPZYP1exY9iNZMhd7ZOSMQSo9WYtiQRw==} + hasBin: true + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} + engines: {node: '>=12.0.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonschema@1.5.0: + resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==} + + launch-editor@2.14.2: + resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npmlog@7.0.1: + resolution: {integrity: sha512-uJ0YFk/mCQpLBt+bxN88AKd+gyqZvZDbtiNxk6Waqcj2aPRyfVx8ITawkyQynxUagInjdYT1+qj4NfA5KJJUxg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + deprecated: This package is no longer supported. + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + ora@5.4.0: + resolution: {integrity: sha512-1StwyXQGoU6gdjYkyVcqOLnVlbKj+6yPNNOxJVgpt9t4eksKjiriiHuxktLYkgllwk+D6MbC4ihH84L1udRXPg==} + engines: {node: '>=10'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + oxfmt@0.73.0: + resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.88.0: + resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} + engines: {node: '>= 0.4'} + + shelljs@0.10.0: + resolution: {integrity: sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==} + engines: {node: '>=18'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssdp-js@1.0.1: + resolution: {integrity: sha512-QxrgpbiD7ItnpBs5UiWVCHk52OiiPjj9KD2rm2susYvrbkRIXWvUg3v8fQxrXWKoSfP8qXt5HmkH/tAPz8j6bA==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@changesets/apply-release-plan@8.1.1': + dependencies: + '@changesets/config': 4.0.1 + '@changesets/format': 0.1.2 + '@changesets/git': 4.0.1 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + import-meta-resolve: 4.2.0 + jsonc-parser: 3.3.1 + semver: 7.8.5 + + '@changesets/assemble-release-plan@7.0.0': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + semver: 7.8.5 + + '@changesets/changelog-git@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/cli@3.0.3': + dependencies: + '@changesets/apply-release-plan': 8.1.1 + '@changesets/assemble-release-plan': 7.0.0 + '@changesets/changelog-git': 1.0.0 + '@changesets/config': 4.0.1 + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/git': 4.0.1 + '@changesets/pre': 3.0.0 + '@changesets/read': 1.0.1 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@changesets/write': 1.0.1 + '@clack/prompts': 1.8.1 + '@manypkg/get-packages': 3.1.0 + '@pnpm/deps.graph-sequencer': 1100.0.1 + cac: 7.1.0 + import-meta-resolve: 4.2.0 + launch-editor: 2.14.2 + package-manager-detector: 1.9.0 + semver: 7.8.5 + tinyexec: 1.3.1 + + '@changesets/config@4.0.1': + dependencies: + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + + '@changesets/errors@1.0.0': {} + + '@changesets/format@0.1.2': + dependencies: + package-manager-detector: 1.9.0 + tinyexec: 1.3.1 + + '@changesets/get-dependents-graph@3.0.0': + dependencies: + '@changesets/types': 7.0.0 + semver: 7.8.5 + + '@changesets/git@4.0.1': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + tinyexec: 1.3.1 + + '@changesets/parse@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + yaml: 2.9.1 + + '@changesets/pre@3.0.0': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + + '@changesets/read@1.0.1': + dependencies: + '@changesets/git': 4.0.1 + '@changesets/parse': 1.0.0 + '@changesets/types': 7.0.0 + + '@changesets/should-skip-package@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/types@7.0.0': {} + + '@changesets/write@1.0.1': + dependencies: + '@changesets/format': 0.1.2 + '@changesets/types': 7.0.0 + human-id: 4.2.2 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@inquirer/external-editor@1.0.3(@types/node@24.19.2)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 24.19.2 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@manypkg/find-root@3.1.0': + dependencies: + '@manypkg/tools': 2.1.2 + + '@manypkg/get-packages@3.1.0': + dependencies: + '@manypkg/find-root': 3.1.0 + '@manypkg/tools': 2.1.2 + + '@manypkg/tools@2.1.2': + dependencies: + jju: 1.4.0 + tinyglobby: 0.2.17 + yaml: 2.9.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxfmt/binding-android-arm-eabi@0.73.0': + optional: true + + '@oxfmt/binding-android-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.73.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.73.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.73.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.88.0': + optional: true + + '@oxlint/binding-android-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-x64@1.88.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.88.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.88.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.88.0': + optional: true + + '@pnpm/deps.graph-sequencer@1100.0.1': {} + + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + optional: true + + '@webos-tools/cli@3.2.6(@types/node@24.19.2)': + dependencies: + ar-async: 0.1.4 + async: 3.2.6 + bluebird: 3.7.2 + body-parser: 2.3.0 + chalk: 4.1.2 + chardet: 2.2.0 + chokidar: 4.0.3 + combined-stream: 1.0.8 + csv-writer: 1.6.0 + easy-table: 1.2.0 + elfy: 1.0.0 + encoding: 0.1.13 + express: 5.2.1 + fs-extra: 11.3.6 + fstream: 1.0.12 + inherits: 2.0.4 + inquirer: 8.2.7(@types/node@24.19.2) + jsonschema: 1.5.0 + mkdirp: 3.0.1 + nopt: 8.1.0 + npmlog: 7.0.1 + ora: 5.4.0 + rimraf: 6.1.3 + semver: 7.7.4 + shelljs: 0.10.0 + sprintf-js: 1.1.3 + ssdp-js: 1.0.1 + ssh2: 1.17.0 + stream-buffers: 3.0.3 + strip-bom: 4.0.0 + tar: 7.5.22 + temp: 0.9.4 + terser: 5.48.0 + transitivePeerDependencies: + - '@types/node' + - supports-color + + abbrev@3.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn@8.18.0: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + aproba@2.1.0: {} + + ar-async@0.1.4: {} + + are-we-there-yet@4.0.2: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + async@3.2.6: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.7.2: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + + bytes@3.1.2: {} + + cac@7.1.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chardet@2.2.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@3.0.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-width@3.0.0: {} + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-support@1.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + concat-map@0.0.1: {} + + console-control-strings@1.1.0: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.28.0 + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csv-writer@1.6.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + easy-table@1.2.0: + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + + ee-first@1.1.1: {} + + elfy@1.0.0: + dependencies: + endian-reader: 0.3.0 + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + + endian-reader@0.3.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + etag@1.8.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + function-bind@1.1.2: {} + + gauge@5.0.2: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 4.1.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-unicode@2.0.1: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-id@4.2.2: {} + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + import-meta-resolve@4.2.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@8.2.7(@types/node@24.19.2): + dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@24.19.2) + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + figures: 3.2.0 + lodash: 4.18.1 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + jju@1.4.0: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonschema@1.5.0: {} + + launch-editor@2.14.2: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.12.0 + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lru-cache@11.5.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mkdirp@3.0.1: {} + + ms@2.1.3: {} + + mute-stream@0.0.8: {} + + nan@2.28.0: + optional: true + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npmlog@7.0.1: + dependencies: + are-we-there-yet: 4.0.2 + console-control-strings: 1.1.0 + gauge: 5.0.2 + set-blocking: 2.0.0 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + ora@5.4.0: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + oxfmt@0.73.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.73.0 + '@oxfmt/binding-android-arm64': 0.73.0 + '@oxfmt/binding-darwin-arm64': 0.73.0 + '@oxfmt/binding-darwin-x64': 0.73.0 + '@oxfmt/binding-freebsd-x64': 0.73.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.73.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.73.0 + '@oxfmt/binding-linux-arm64-gnu': 0.73.0 + '@oxfmt/binding-linux-arm64-musl': 0.73.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-musl': 0.73.0 + '@oxfmt/binding-linux-s390x-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-musl': 0.73.0 + '@oxfmt/binding-openharmony-arm64': 0.73.0 + '@oxfmt/binding-win32-arm64-msvc': 0.73.0 + '@oxfmt/binding-win32-ia32-msvc': 0.73.0 + '@oxfmt/binding-win32-x64-msvc': 0.73.0 + + oxlint@1.88.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.88.0 + '@oxlint/binding-android-arm64': 1.88.0 + '@oxlint/binding-darwin-arm64': 1.88.0 + '@oxlint/binding-darwin-x64': 1.88.0 + '@oxlint/binding-freebsd-x64': 1.88.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.88.0 + '@oxlint/binding-linux-arm-musleabihf': 1.88.0 + '@oxlint/binding-linux-arm64-gnu': 1.88.0 + '@oxlint/binding-linux-arm64-musl': 1.88.0 + '@oxlint/binding-linux-ppc64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-musl': 1.88.0 + '@oxlint/binding-linux-s390x-gnu': 1.88.0 + '@oxlint/binding-linux-x64-gnu': 1.88.0 + '@oxlint/binding-linux-x64-musl': 1.88.0 + '@oxlint/binding-openharmony-arm64': 1.88.0 + '@oxlint/binding-win32-arm64-msvc': 1.88.0 + '@oxlint/binding-win32-ia32-msvc': 1.88.0 + '@oxlint/binding-win32-x64-msvc': 1.88.0 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.9.0: {} + + parseurl@1.3.3: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-async@2.4.1: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setprototypeof@1.2.0: {} + + sharp@0.35.5(@types/node@24.19.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 24.19.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.12.0: {} + + shelljs@0.10.0: + dependencies: + execa: 5.1.1 + fast-glob: 3.3.3 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.1.3: {} + + ssdp-js@1.0.1: {} + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.28.0 + + statuses@2.0.2: {} + + stream-buffers@3.0.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + through@2.3.8: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.2.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tslib@2.8.1: {} + + tweetnacl@0.14.5: {} + + type-fest@0.21.3: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + undici-types@7.24.6: + optional: true + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + yallist@5.0.0: {} + + yaml@2.9.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7ea0b9d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +onlyBuiltDependencies: + - sharp + +# webOS CLI's postinstall only changes bundled config permissions. SSH's optional +# native accelerators aren't required; its portable JavaScript transport is used. +ignoredBuiltDependencies: + - '@webos-tools/cli' + - cpu-features + - ssh2 diff --git a/src/app-artwork-tests.rs b/src/app-artwork-tests.rs new file mode 100644 index 0000000..d4886a9 --- /dev/null +++ b/src/app-artwork-tests.rs @@ -0,0 +1,401 @@ +use super::*; +use image::{DynamicImage, ImageFormat, Rgba}; +use serde_json::json; +use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::{Duration, Instant}, +}; + +fn encoded(format: ImageFormat, width: u32, height: u32, color: [u8; 4]) -> Vec { + let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(width, height, Rgba(color))); + let image = if format == ImageFormat::Jpeg { + DynamicImage::ImageRgb8(image.into_rgb8()) + } else { + image + }; + let mut bytes = Cursor::new(Vec::new()); + image.write_to(&mut bytes, format).unwrap(); + bytes.into_inner() +} + +fn png(image: RgbaImage) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut bytes, ImageFormat::Png) + .unwrap(); + bytes.into_inner() +} + +fn poll_until(service: &Service, mut done: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + service.poll(); + if done() { + break; + } + assert!(Instant::now() < deadline, "artwork worker did not finish"); + thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn inventory_metadata_resolves_absolute_relative_and_manifest_fallback_paths() { + let metadata = json!({"id":"sample", "title":"Regional App", "icon":"regional.png", + "largeIcon":"large.png", "folderPath":format!("{}/sample", ROOTS[1]), "version":"3.1.0"}); + let apps = crate::platform::normalize_apps(&json!([metadata.clone()])).unwrap(); + assert_eq!(apps[0].icon, Some(Source::new("sample", &metadata))); + assert_eq!( + apps[0].icon.as_ref().unwrap().version.as_deref(), + Some("3.1.0") + ); + let source = Source::new( + "sample", + &json!({ + "folderPath": format!("{}/actual-package-directory", ROOTS[1]), + "largeIcon": "resources/hu/large.png", "icon": "icon.png" + }), + ); + let dirs = source.directories(); + assert_eq!( + dirs[0], + Path::new(ROOTS[1]).join("actual-package-directory") + ); + assert_eq!( + icon_path(&dirs[0], source.large_icon.as_deref().unwrap()), + Some(dirs[0].join("resources/hu/large.png")) + ); + let absolute = format!("file://{}/sample/icon.png", ROOTS[2]); + assert_eq!( + icon_path(&dirs[0], &absolute), + Some(Path::new(ROOTS[2]).join("sample/icon.png")) + ); + for bad in [ + "../../etc/passwd", + "/etc/passwd", + "https://example.com/icon.png", + "icon\0.png", + ] { + assert!(icon_path(&dirs[0], bad).is_none()); + } + let source = Source::new("sample", &json!({"folderPath":"/etc"})); + assert_eq!(source.directories().len(), 3); + assert_eq!(source.directories()[0], Path::new(ROOTS[0]).join("sample")); +} + +#[test] +fn supported_icons_decode_to_bounded_pixels_and_runtime_card_colours() { + for format in [ImageFormat::Png, ImageFormat::Jpeg, ImageFormat::WebP] { + let artwork = decode(&encoded(format, 400, 200, [48, 80, 112, 255])).unwrap(); + assert_eq!(artwork.pixels.dimensions(), (256, 128)); + for (actual, expected) in artwork.color.into_iter().zip([48u8, 80, 112]) { + assert!(actual.abs_diff(expected) <= 2); + } + } + let transparent = decode(&encoded(ImageFormat::Png, 32, 32, [255, 0, 0, 0])).unwrap(); + assert_eq!(transparent.color, [20; 3]); + assert!(decode(b"not an image").is_none()); + assert!(decode(&encoded(ImageFormat::Png, 2049, 1, [0; 4])).is_none()); +} + +#[test] +fn transparent_logos_reaching_the_edges_keep_the_neutral_card() { + // Like Litefin's 130px icon: a ring logo on a transparent background whose + // stroke reaches into the sampled edge band. + let ring = RgbaImage::from_fn(130, 130, |x, y| { + let distance = (x as f32 - 64.5).hypot(y as f32 - 64.5); + if (52.0..62.0).contains(&distance) { + Rgba([150, 100, 210, 255]) + } else { + Rgba([0; 4]) + } + }); + assert_eq!(decode(&png(ring)).unwrap().color, [20; 3]); + // Opaque artwork with transparent rounded corners still takes its edge colour. + let rounded = RgbaImage::from_fn(130, 130, |x, y| { + let inset = |v: u32| v.min(129 - v) as f32; + let (dx, dy) = (24. - inset(x), 24. - inset(y)); + if dx > 0. && dy > 0. && dx.hypot(dy) > 24. { + Rgba([0; 4]) + } else { + Rgba([40, 90, 160, 255]) + } + }); + let color = decode(&png(rounded)).unwrap().color; + for (actual, expected) in color.into_iter().zip([40u8, 90, 160]) { + assert!(actual.abs_diff(expected) <= 2); + } +} + +#[test] +fn local_reads_fall_back_from_a_corrupt_large_icon_to_manifest_artwork() { + let directory = Path::new(ROOTS[1]).join("sample"); + let manifest = json!({"id":"sample", "largeIcon":"corrupt.png", "icon":"local/icon.webp"}); + let files = HashMap::from([ + ( + directory.join("appinfo.json"), + serde_json::to_vec(&manifest).unwrap(), + ), + (directory.join("corrupt.png"), b"broken".to_vec()), + ( + directory.join("local/icon.webp"), + encoded(ImageFormat::WebP, 32, 32, [8, 16, 24, 255]), + ), + ]); + let source = Source::new("sample", &json!({"folderPath": directory})); + let mut reads = Vec::new(); + let image = load_local(&source, |path, limit| { + reads.push(path.to_owned()); + files + .get(path) + .filter(|bytes| bytes.len() <= limit) + .cloned() + }) + .unwrap(); + assert_eq!(image.pixels.get_pixel(0, 0).0, [8, 16, 24, 255]); + assert_eq!(reads[0], directory.join("appinfo.json")); + assert!(reads.contains(&directory.join("corrupt.png"))); +} + +#[test] +fn refresh_discards_old_worker_results_and_reloads_changed_artwork() { + let calls = Arc::new(AtomicUsize::new(0)); + let worker_calls = calls.clone(); + let (started, received) = mpsc::channel(); + let ui_thread = thread::current().id(); + let service = Service::with_loader(true, move |_| { + assert_ne!(thread::current().id(), ui_thread); + let number = worker_calls.fetch_add(1, Ordering::SeqCst) + 1; + started.send(number).unwrap(); + decode(&encoded(ImageFormat::Png, 2, 2, [number as u8, 0, 0, 255])) + }); + let source = Source::new("sample", &json!({})); + assert!(service.get(&source).is_none()); + assert_eq!(received.recv_timeout(Duration::from_secs(3)).unwrap(), 1); + service.reset(); + assert!(service.get(&source).is_none()); + poll_until(&service, || service.get(&source).is_some()); + let image = service.get(&source).unwrap(); + assert_eq!(image.pixels.get_pixel(0, 0)[0], 2); + assert!(Arc::ptr_eq(&image, &service.get(&source).unwrap())); + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn the_worker_keeps_decoding_while_the_ui_is_idle() { + let calls = Arc::new(AtomicUsize::new(0)); + let worker_calls = calls.clone(); + let service = Service::with_loader(true, move |_| { + worker_calls.fetch_add(1, Ordering::SeqCst); + decode(&encoded(ImageFormat::Png, 2, 2, [1, 2, 3, 255])) + }); + let sources: Vec<_> = (0..5) + .map(|number| Source::new(&format!("app-{number}"), &json!({}))) + .collect(); + for source in &sources { + assert!(service.get(source).is_none()); + } + assert!(service.pending()); + // No poll yet: an idle UI must not hold the worker to one finished icon. + let deadline = Instant::now() + Duration::from_secs(3); + while calls.load(Ordering::SeqCst) < sources.len() { + assert!( + Instant::now() < deadline, + "worker stalled until the UI polled" + ); + thread::sleep(Duration::from_millis(1)); + } + poll_until(&service, || { + sources.iter().all(|source| service.get(source).is_some()) + }); + assert!(!service.pending()); +} + +#[test] +fn a_refresh_keeps_unchanged_icons_and_reads_updated_apps_again() { + let calls = Arc::new(AtomicUsize::new(0)); + let worker_calls = calls.clone(); + let ui_thread = thread::current().id(); + let service = Service::with_loader(true, move |_| { + assert_ne!(thread::current().id(), ui_thread); + let number = worker_calls.fetch_add(1, Ordering::SeqCst) + 1; + decode(&encoded(ImageFormat::Png, 2, 2, [number as u8, 0, 0, 255])) + }); + let installed = Source::new("sample", &json!({"version": "1.0.0"})); + assert!(service.get(&installed).is_none()); + poll_until(&service, || service.get(&installed).is_some()); + let image = service.get(&installed).unwrap(); + service.refresh(); + // Unchanged after the refresh: the same decoded artwork, not read again. + assert!(Arc::ptr_eq(&image, &service.get(&installed).unwrap())); + assert_eq!(calls.load(Ordering::SeqCst), 1); + // An update changes the version, so its icon is read again. + let updated = Source::new("sample", &json!({"version": "1.1.0"})); + assert_ne!(installed, updated); + assert!(service.get(&updated).is_none()); + poll_until(&service, || service.get(&updated).is_some()); + assert_eq!(service.get(&updated).unwrap().pixels.get_pixel(0, 0)[0], 2); + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn a_refresh_during_a_load_keeps_its_result() { + let calls = Arc::new(AtomicUsize::new(0)); + let worker_calls = calls.clone(); + let (release, hold) = mpsc::channel::<()>(); + let service = Service::with_loader(true, move |_| { + worker_calls.fetch_add(1, Ordering::SeqCst); + hold.recv().unwrap(); + decode(&encoded(ImageFormat::Png, 2, 2, [1, 0, 0, 255])) + }); + let source = Source::new("sample", &json!({})); + assert!(service.get(&source).is_none()); + service.refresh(); + release.send(()).unwrap(); + poll_until(&service, || service.get(&source).is_some()); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn unavailable_icons_are_not_retried_on_every_redraw() { + let calls = Arc::new(AtomicUsize::new(0)); + let worker_calls = calls.clone(); + let service = Service::with_loader(true, move |_| { + worker_calls.fetch_add(1, Ordering::SeqCst); + None + }); + let source = Source::new("sample", &json!({})); + assert!(service.get(&source).is_none()); + poll_until(&service, || { + service.cache.borrow().entries[&source].image.is_some() + }); + for _ in 0..10 { + assert!(service.get(&source).is_none()); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + // Unavailable icons are tried again after a refresh. + service.refresh(); + assert!(service.get(&source).is_none()); + poll_until(&service, || { + service.cache.borrow().entries[&source].image.is_some() + }); + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn hundreds_of_apps_stay_within_the_cache_bounds_across_refreshes() { + let service = Service::with_loader(true, |_| { + Some(Artwork { + key: String::new(), + pixels: RgbaImage::new(256, 256), + color: [20; 3], + }) + }); + for number in 0..300 { + if number % 100 == 0 { + service.refresh(); + } + let source = Source::new(&format!("app-{number}"), &json!({"version": "1"})); + service.get(&source); + poll_until(&service, || { + service.cache.borrow().entries[&source].image.is_some() + }); + } + let cache = service.cache.borrow(); + let bytes: usize = cache + .entries + .values() + .filter_map(|entry| entry.image.as_ref()?.as_ref()) + .map(|image| image.pixels.as_raw().len()) + .sum(); + assert!(bytes <= CACHE_BYTES); + assert!(cache.entries.len() <= CACHE_ENTRIES); +} + +#[test] +fn decoded_cache_evicts_old_icons_when_scrolling_through_many_apps() { + let service = Service::with_loader(true, |_| { + Some(Artwork { + key: String::new(), + pixels: RgbaImage::new(256, 256), + color: [20; 3], + }) + }); + for number in 0..80 { + let source = Source::new(&format!("app-{number}"), &json!({})); + service.get(&source); + poll_until(&service, || { + service.cache.borrow().entries[&source].image.is_some() + }); + } + let cache = service.cache.borrow(); + let bytes: usize = cache + .entries + .values() + .filter_map(|entry| entry.image.as_ref()?.as_ref()) + .map(|image| image.pixels.as_raw().len()) + .sum(); + assert!(bytes <= CACHE_BYTES); + assert!( + !cache + .entries + .contains_key(&Source::new("app-0", &json!({}))) + ); + assert!( + cache + .entries + .contains_key(&Source::new("app-79", &json!({}))) + ); +} + +#[test] +fn visible_cards_switch_from_fallback_to_installed_artwork_without_an_icon_map() { + let assets = crate::view::Assets { + root: PathBuf::new(), + version: "test".into(), + metrics: Default::default(), + artwork: Service::with_loader(true, |_| { + decode(&encoded(ImageFormat::Png, 32, 32, [24, 40, 56, 255])) + }), + }; + let apps = + crate::platform::normalize_apps(&json!([{"id":"regional.app", "title":"Regional App"}])) + .unwrap(); + let mut state = crate::state::State::new(Default::default()); + state.set_inventory( + crate::model::Inventory { + apps, + inputs: vec![], + }, + None, + ); + let scene = || { + crate::view::scene_with_pill( + &state, + &assets, + 800., + 100., + time::OffsetDateTime::UNIX_EPOCH, + ) + }; + assert!( + !scene() + .draw + .iter() + .any(|draw| matches!(draw, crate::renderer::Draw::AppImage(..))) + ); + poll_until(&assets.artwork, || { + scene() + .draw + .iter() + .any(|draw| matches!(draw, crate::renderer::Draw::AppImage(..))) + }); + let image = scene() + .draw + .into_iter() + .find_map(|draw| match draw { + crate::renderer::Draw::AppImage(image, _, _) => Some(image), + _ => None, + }) + .unwrap(); + assert_eq!(image.color, [24, 40, 56]); +} diff --git a/src/app_artwork.rs b/src/app_artwork.rs new file mode 100644 index 0000000..a9ef9d6 --- /dev/null +++ b/src/app_artwork.rs @@ -0,0 +1,375 @@ +//! Installed-app artwork is discovered and decoded on its own worker. The UI +//! requests visible cards only; it uploads finished pixels on the SDL thread. +use crate::model::valid_app_id; +use image::{ImageReader, Limits, RgbaImage}; +use serde::Serialize; +use serde_json::Value; +use std::{ + cell::RefCell, + collections::HashMap, + fs::File, + io::{Cursor, Read}, + path::{Component, Path, PathBuf}, + sync::{Arc, mpsc}, + thread, +}; + +const ROOTS: [&str; 3] = [ + "/media/developer/apps/usr/palm/applications", + "/media/cryptofs/apps/usr/palm/applications", + "/usr/palm/applications", +]; +pub const MAX_FILE: usize = 512 * 1024; +const CACHE_BYTES: usize = 16 * 1024 * 1024; +const CACHE_ENTRIES: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +pub struct Source { + pub id: String, + directory: Option, + large_icon: Option, + icon: Option, + // Part of the cache key: an app update changes it, so its icon is read + // again, while unchanged apps keep their decoded artwork across refreshes. + version: Option, +} + +impl Source { + pub fn new(id: &str, metadata: &Value) -> Self { + let text = |key| { + metadata + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && s.len() <= 4096 && !s.chars().any(char::is_control)) + }; + Self { + id: id.into(), + directory: text("folderPath").map(PathBuf::from).filter(|p| { + p.components().all(|c| !matches!(c, Component::ParentDir)) + && ROOTS.iter().any(|root| p.parent() == Some(Path::new(root))) + }), + large_icon: text("largeIcon").map(str::to_owned), + icon: text("icon").map(str::to_owned), + version: text("version").map(str::to_owned), + } + } + + fn directories(&self) -> Vec { + let mut directories: Vec<_> = self.directory.iter().cloned().collect(); + for root in ROOTS { + let directory = Path::new(root).join(&self.id); + if !directories.contains(&directory) { + directories.push(directory); + } + } + directories + } +} + +pub struct Artwork { + pub key: String, + pub pixels: RgbaImage, + pub color: [u8; 3], +} + +fn read_bounded(path: &Path, limit: usize) -> Option> { + let file = File::open(path).ok()?; + let metadata = file.metadata().ok()?; + if !metadata.is_file() || metadata.len() > limit as u64 { + return None; + } + let mut bytes = Vec::new(); + file.take(limit as u64 + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() <= limit).then_some(bytes) +} + +fn icon_path(directory: &Path, value: &str) -> Option { + if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) { + return None; + } + let value = value.strip_prefix("file://").unwrap_or(value); + if value.contains("://") { + return None; + } + let path = Path::new(value); + if path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return None; + } + let path = if path.is_absolute() { + path.to_owned() + } else { + directory.join(path) + }; + ROOTS + .iter() + .any(|root| path.starts_with(root)) + .then_some(path) +} + +fn decode(bytes: &[u8]) -> Option { + let mut reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + let mut limits = Limits::default(); + limits.max_image_width = Some(2048); + limits.max_image_height = Some(2048); + limits.max_alloc = Some(32 * 1024 * 1024); + reader.limits(limits); + let image = reader.decode().ok()?; + let pixels = image.thumbnail(256, 256).into_rgba8(); + let color = background(&pixels); + Some(Artwork { + key: String::new(), + pixels, + color, + }) +} + +fn background(image: &RgbaImage) -> [u8; 3] { + // Match the icon's opaque edges rather than letting its central logo choose + // the card colour. Transparent artwork gets a neutral dark background, even + // when its logo reaches into the edge band: those few opaque pixels would + // otherwise fill the card with the logo's own colour and hide it. + let sample = image::imageops::resize(image, 48, 48, image::imageops::FilterType::Triangle); + let mut bins = HashMap::<[u8; 3], (usize, [usize; 3])>::new(); + let (mut edge, mut opaque) = (0, 0); + for (x, y, pixel) in sample.enumerate_pixels() { + if (6..42).contains(&x) && (6..42).contains(&y) { + continue; + } + edge += 1; + if pixel[3] < 230 { + continue; + } + opaque += 1; + let bin = bins + .entry([pixel[0] >> 4, pixel[1] >> 4, pixel[2] >> 4]) + .or_default(); + bin.0 += 1; + for channel in 0..3 { + bin.1[channel] += pixel[channel] as usize; + } + } + if opaque * 2 < edge { + return [20; 3]; + } + bins.into_iter() + .max_by_key(|(key, (count, _))| (*count, *key)) + .map_or([20; 3], |(_, (count, sum))| { + sum.map(|s| ((s + count / 2) / count) as u8) + }) +} + +fn load(source: &Source) -> Option { + if !valid_app_id(&source.id) { + return None; + } + if let Some(image) = load_local(source, read_bounded) { + return Some(image); + } + // Some jailed installations cannot read other applications' files. Use the + // same native-authenticated elevated service as inventory, on this worker. + match crate::platform::read_app_icons(source) { + Ok(icons) => icons.iter().find_map(|bytes| decode(bytes)), + Err(error) => { + eprintln!("Luma installed icon {}: {error}", source.id); + None + } + } +} + +fn load_local( + source: &Source, + mut read: impl FnMut(&Path, usize) -> Option>, +) -> Option { + for directory in source.directories() { + let manifest = read(&directory.join("appinfo.json"), 64 * 1024) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|value| value.get("id").and_then(Value::as_str) == Some(&source.id)); + let manifest_icon = |key| { + manifest + .as_ref() + .and_then(|m| m.get(key)) + .and_then(Value::as_str) + }; + for icon in [ + source.large_icon.as_deref(), + manifest_icon("largeIcon"), + source.icon.as_deref(), + manifest_icon("icon"), + Some("icon.png"), + ] + .into_iter() + .flatten() + { + if let Some(artwork) = icon_path(&directory, icon) + .and_then(|path| read(&path, MAX_FILE)) + .and_then(|bytes| decode(&bytes)) + { + return Some(artwork); + } + } + } + None +} + +struct Request { + token: u64, + source: Source, +} +struct Reply { + request: Request, + image: Option>, +} +struct Entry { + token: u64, + used: u64, + // None: pending; Some(None): unavailable until the next inventory refresh. + image: Option>>, +} +#[derive(Default)] +struct Cache { + clock: u64, + entries: HashMap, +} + +pub struct Service { + sender: Option>, + receiver: mpsc::Receiver, + cache: RefCell, +} + +impl Service { + pub fn new(enabled: bool) -> Self { + Self::with_loader(enabled, load) + } + + fn with_loader( + enabled: bool, + loader: impl Fn(&Source) -> Option + Send + 'static, + ) -> Self { + let (sender, requests) = mpsc::sync_channel::(16); + // Room for a full request queue: an idle UI only polls on its next + // wake, and a one-slot reply channel held the worker to one icon each. + let (responses, receiver) = mpsc::sync_channel(16); + if enabled { + thread::spawn(move || { + while let Ok(request) = requests.recv() { + let image = loader(&request.source).map(|mut image| { + image.key = format!("installed-icon:{}", request.token); + Arc::new(image) + }); + if responses.send(Reply { request, image }).is_err() { + break; + } + } + }); + } + Self { + sender: enabled.then_some(sender), + receiver, + cache: RefCell::default(), + } + } + + pub fn get(&self, source: &Source) -> Option> { + let sender = self.sender.as_ref()?; + let mut cache = self.cache.borrow_mut(); + cache.clock += 1; + let clock = cache.clock; + if let Some(entry) = cache.entries.get_mut(source) { + entry.used = clock; + return entry.image.clone().flatten(); + } + let request = Request { + token: clock, + source: source.clone(), + }; + // A full queue is retried when the next completed request redraws the + // scene. Neither scrolling nor a slow Homebrew service can block input. + if sender.try_send(request).is_ok() { + cache.entries.insert( + source.clone(), + Entry { + token: clock, + used: clock, + image: None, + }, + ); + } + None + } + + /// Whether a requested icon has not come back yet, so the event loop + /// should wake soon to poll for it. + pub fn pending(&self) -> bool { + self.cache + .borrow() + .entries + .values() + .any(|entry| entry.image.is_none()) + } + + /// Refresh apps: every icon is read again. Replies to earlier requests + /// no longer match an entry's token and are dropped. + pub fn reset(&self) { + self.cache.borrow_mut().entries.clear(); + } + + /// After an automatic inventory refresh: icons that were unavailable are + /// tried again. Decoded and pending icons stay, because a `Source` names + /// the app's version and icon paths, so an updated app has a new one. + pub fn refresh(&self) { + self.cache + .borrow_mut() + .entries + .retain(|_, entry| !matches!(entry.image, Some(None))); + } + + pub fn poll(&self) -> bool { + let mut changed = false; + let mut cache = self.cache.borrow_mut(); + while let Ok(reply) = self.receiver.try_recv() { + changed = true; + if let Some(entry) = cache.entries.get_mut(&reply.request.source) + && entry.token == reply.request.token + { + entry.image = Some(reply.image); + } + } + if !changed { + return false; + } + let mut bytes: usize = cache + .entries + .values() + .filter_map(|e| e.image.as_ref()?.as_ref()) + .map(|i| i.pixels.as_raw().len()) + .sum(); + let mut old: Vec<_> = cache + .entries + .iter() + .filter(|(_, e)| e.image.is_some()) + .map(|(s, e)| (s.clone(), e.used)) + .collect(); + old.sort_by_key(|(_, used)| *used); + for (source, _) in old { + if bytes <= CACHE_BYTES && cache.entries.len() <= CACHE_ENTRIES { + break; + } + if let Some(entry) = cache.entries.remove(&source) + && let Some(Some(image)) = entry.image + { + bytes -= image.pixels.as_raw().len(); + } + } + changed + } +} + +#[cfg(test)] +#[path = "app-artwork-tests.rs"] +mod tests; diff --git a/src/branding.rs b/src/branding.rs new file mode 100644 index 0000000..fb23375 --- /dev/null +++ b/src/branding.rs @@ -0,0 +1,220 @@ +//! Curated artwork is an exact application-ID allowlist. Inventory titles and +//! partial ID matches must never replace another application's installed icon. +#[derive(Debug)] +pub struct Brand { + pub key: &'static str, + pub title: &'static str, + pub ids: &'static [&'static str], + pub category: &'static str, +} + +impl Brand { + /// Optical bounds, rather than a shared bounding box: heavy wordmarks need + /// more breathing room than wide, fine-lettered logos such as YouTube. + pub fn artwork_size(&self) -> (f32, f32) { + match self.key { + "netflix" => (0.76, 88.), + "youtube" => (0.74, 88.), + "apple-tv" => (0.43, 88.), + "disney-plus" => (0.72, 105.6), + "prime-video" => (0.72, 88.), + "jellyfin" => (0.83, 88.), + "arte" => (0.58, 88.), + "zdf" => (0.50, 88.), + "das-erste" => (0.70, 88.), + "hulu" | "joyn" | "itvx" => (0.62, 48.), + "nrk-tv" => (0.62, 56.), + "tv2-play" => (0.56, 80.), + "plex" => (0.62, 52.), + "filmin" => (0.62, 46.), + "hbo-max" | "bbc-iplayer" | "crunchyroll" | "twitch" | "dazn" | "paramount-plus" + | "channel-4" | "wavve" => (0.70, 64.), + "now-tv" => (0.70, 52.), + "live-tv" | "browser" => (0.62, 68.), + _ => (0.70, 56.), + } + } +} + +const fn video(key: &'static str, title: &'static str, ids: &'static [&'static str]) -> Brand { + Brand { + key, + title, + ids, + category: "Streaming", + } +} +const fn music(key: &'static str, title: &'static str, ids: &'static [&'static str]) -> Brand { + Brand { + key, + title, + ids, + category: "Music", + } +} + +pub const BRANDS: &[Brand] = &[ + video("live-tv", "Live TV", &["com.webos.app.livetv"]), + Brand { + key: "browser", + title: "Web Browser", + ids: &["com.webos.app.browser"], + category: "Utilities", + }, + video("netflix", "Netflix", &["netflix"]), + video("youtube", "YouTube", &["youtube.leanback.v4"]), + video( + "apple-tv", + "Apple TV", + &["com.apple.appletv", "com.apple.appletv.web"], + ), + video("disney-plus", "Disney+", &["com.disney.disneyplus-prod"]), + video("prime-video", "Prime Video", &["amazon"]), + video("jellyfin", "Jellyfin", &["org.jellyfin.webos"]), + video("arte", "ARTE", &["com.arte.tv", "com.3827031.168353"]), + video("zdf", "ZDF", &["de.zdf.app.zdfm3"]), + video("das-erste", "ARD Mediathek", &["ard.mediathek"]), + music( + "spotify", + "Spotify", + &["spotify-beehive", "com.spotify.tv.webos"], + ), + video("plex", "Plex", &["cdp-30", "plex"]), + video( + "hbo-max", + "HBO Max", + &["com.wbd.stream", "com.hbo.hbomax", "hbo-go-2"], + ), + video( + "paramount-plus", + "Paramount+", + &["com.pplus.webapp.prod-intl", "cbs"], + ), + video("peacock", "Peacock", &["com.peacocktv.peacock"]), + video("hulu", "Hulu", &["hulu"]), + video( + "crunchyroll", + "Crunchyroll", + &["crunchyroll", "com.crunchyroll.webos"], + ), + video("twitch", "Twitch", &["tv.twitch.tv.starshot.lg", "twitch"]), + video("dazn", "DAZN", &["dazn"]), + video("rakuten-tv", "Rakuten TV", &["ui30"]), + music( + "apple-music", + "Apple Music", + &["com.apple.music", "com.apple.ampl"], + ), + music( + "amazon-music", + "Amazon Music", + &["com.theadelab.amclient-lg"], + ), + video( + "bbc-iplayer", + "BBC iPlayer", + &["bbc.iplayer.3.0", "bbc.iplayer"], + ), + video("itvx", "ITVX", &["com.fvp.itv", "itvx"]), + video( + "channel-4", + "Channel 4", + &["com.fvp.ch4", "channel4", "all4.app"], + ), + video("now-tv", "NOW / Now TV", &["now.tv", "nowtv"]), + video("rtl-plus", "RTL+", &["tvnow", "com.tvnow.webos"]), + video("joyn", "Joyn", &["joyn"]), + video("waipu-tv", "waipu.tv", &["tv.waipu.app.waipu-lg"]), + video("zattoo", "Zattoo", &["zattoo", "com.zattoo.webapp"]), + video( + "skyshowtime", + "SkyShowtime", + &["com.skyshowtime.tv", "com.skyshowtime.skyshowtime"], + ), + video("coupang-play", "Coupang Play", &["coupangplay"]), + video("tving", "TVING", &["cj.eandm"]), + video("wavve", "Wavve", &["pooq"]), + video("watcha", "Watcha Play", &["com.frograms.watchaplay.webos"]), + video("movistar-plus", "Movistar+", &["telefonica"]), + video("filmin", "Filmin", &["filmin"]), + video("nrk-tv", "NRK TV", &["no.nrk.webos.smarttv"]), + video("tv2-play", "TV 2 Play", &["tv2sumo"]), +]; + +pub fn for_app(id: &str) -> Option<&'static Brand> { + BRANDS.iter().find(|brand| brand.ids.contains(&id)) +} + +pub fn for_key(key: &str) -> Option<&'static Brand> { + BRANDS.iter().find(|brand| brand.key == key) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn every_curated_id_is_unique_and_selects_its_brand_without_title_matching() { + let mut ids = HashSet::new(); + for brand in BRANDS { + assert!(!brand.ids.is_empty()); + assert!( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join(format!("assets/logos/{}.svg", brand.key)) + .is_file(), + "{} has no vector source", + brand.key + ); + for id in brand.ids { + assert!(ids.insert(id), "Duplicate curated app ID: {id}"); + let app = crate::model::App::new(id, "A localized application title"); + assert_eq!(app.brand, brand.key); + assert_eq!(app.category, brand.category); + assert!(for_app(&format!("{id}.unrelated")).is_none()); + } + } + } + + #[test] + fn now_tv_aliases_select_now_without_rebranding_other_sky_or_now_apps() { + for id in ["now.tv", "nowtv"] { + assert_eq!(for_app(id).unwrap().key, "now-tv"); + } + for id in [ + "de.sky.skyonline", + "com.skygo.app.de.q", + "now.tv.news", + "com.nowtv.hk", + ] { + assert!(for_app(id).is_none()); + } + } + + #[test] + fn desktop_demo_contains_every_bundled_logo_and_no_duplicate_apps() { + let inventory = crate::model::demo_inventory(); + let ids: HashSet<_> = inventory.apps.iter().map(|app| &app.id).collect(); + assert_eq!(ids.len(), inventory.apps.len()); + for brand in BRANDS { + assert!( + inventory.apps.iter().any(|app| app.brand == brand.key), + "The desktop demo is missing {}", + brand.title + ); + } + for entry in + std::fs::read_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/logos")) + .unwrap() + { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|ext| ext == "svg") { + assert!( + for_key(path.file_stem().unwrap().to_str().unwrap()).is_some(), + "Bundled logo has no demo card: {}", + path.display() + ); + } + } + } +} diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..b61c035 --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,86 @@ +//! Presentation pacing is measured only in forced-redraw profiling mode. Idle +//! wakeups are not frames, and CPU submission time is not a GPU timestamp. +use serde::Serialize; +use std::time::Instant; + +const WARMUP: usize = 10; + +#[derive(Default)] +pub struct Profile { + presented: usize, + previous: Option, + intervals: Vec, + cpu: Vec, +} +#[derive(Serialize)] +pub struct Timing { + pub samples: usize, + pub mean_ms: f64, + pub p95_ms: f64, + pub max_ms: f64, +} +#[derive(Serialize)] +pub struct Summary { + pub presented: usize, + pub warmup_frames: usize, + pub pacing: Option, + pub cpu_submission: Option, +} +impl Profile { + pub fn record(&mut self, now: Instant, cpu_ms: f64, exclude: bool) { + self.presented += 1; + if self.presented > WARMUP + && !exclude + && let Some(previous) = self.previous + { + self.intervals + .push(now.duration_since(previous).as_secs_f64() * 1000.); + self.cpu.push(cpu_ms); + } + self.previous = Some(now); + } + pub fn pause(&mut self) { + self.previous = None; + } + pub fn summary(&self) -> Summary { + Summary { + presented: self.presented, + warmup_frames: WARMUP, + pacing: summarize(&self.intervals), + cpu_submission: summarize(&self.cpu), + } + } +} +fn summarize(samples: &[f64]) -> Option { + if samples.is_empty() { + return None; + } + let mut sorted = samples.to_vec(); + sorted.sort_by(f64::total_cmp); + Some(Timing { + samples: sorted.len(), + mean_ms: sorted.iter().sum::() / sorted.len() as f64, + p95_ms: sorted[(sorted.len() * 95).div_ceil(100) - 1], + max_ms: *sorted.last()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + #[test] + fn profiling_excludes_warmup_and_capture_stalls() { + let mut profile = Profile::default(); + let start = Instant::now(); + for index in 0..20 { + profile.record(start + Duration::from_millis(index * 16), 2., index == 19); + } + let summary = profile.summary(); + let pacing = summary.pacing.unwrap(); + assert_eq!(pacing.samples, 9); + assert_eq!(pacing.mean_ms, 16.); + assert_eq!(pacing.p95_ms, 16.); + assert_eq!(summary.cpu_submission.unwrap().mean_ms, 2.); + } +} diff --git a/src/font.rs b/src/font.rs new file mode 100644 index 0000000..42b0b29 --- /dev/null +++ b/src/font.rs @@ -0,0 +1,126 @@ +//! Lazy TTF metrics and individual outlines; never preprocess an entire font. +use geometry::lyon_tessellation::{ + FillRule, + math::point, + path::{Path, path::Builder}, +}; +use luma_vector_geometry::{self as geometry, Vertex}; +use owned_ttf_parser::{AsFaceRef, GlyphId, OutlineBuilder, OwnedFace}; + +pub struct Font { + face: OwnedFace, +} + +pub struct LineMetrics { + pub ascent: f32, + pub descent: f32, +} + +pub struct Metrics { + pub advance_width: f32, +} + +impl Font { + pub fn from_bytes(bytes: Vec) -> Result { + OwnedFace::from_vec(bytes, 0) + .map(|face| Self { face }) + .map_err(|e| e.to_string()) + } + + pub fn lookup_glyph_index(&self, character: char) -> u16 { + self.face + .as_face_ref() + .glyph_index(character) + .map_or(0, |id| id.0) + } + + pub fn horizontal_line_metrics(&self, size: f32) -> Option { + let face = self.face.as_face_ref(); + let scale = size / f32::from(face.units_per_em()); + Some(LineMetrics { + ascent: f32::from(face.ascender()) * scale, + descent: f32::from(face.descender()) * scale, + }) + } + + pub fn metrics(&self, character: char, size: f32) -> Metrics { + let face = self.face.as_face_ref(); + let glyph = GlyphId(self.lookup_glyph_index(character)); + Metrics { + advance_width: f32::from(face.glyph_hor_advance(glyph).unwrap_or(0)) * size + / f32::from(face.units_per_em()), + } + } + + pub fn horizontal_kern(&self, left: char, right: char, size: f32) -> Option { + let face = self.face.as_face_ref(); + let left = face.glyph_index(left)?; + let right = face.glyph_index(right)?; + for table in face.tables().kern?.subtables { + if table.horizontal + && !table.has_cross_stream + && let Some(value) = table.glyphs_kerning(left, right) + { + return Some(f32::from(value) * size / f32::from(face.units_per_em())); + } + } + None + } + + pub fn geometry(&self, character: char) -> Result, String> { + let face = self.face.as_face_ref(); + let mut outline = GlyphOutline { + builder: Path::builder(), + scale: 1. / f32::from(face.units_per_em()), + open: false, + }; + let glyph = GlyphId(self.lookup_glyph_index(character)); + if face.outline_glyph(glyph, &mut outline).is_none() { + return Ok(Vec::new()); + } + if outline.open { + outline.builder.end(true); + } + // Flatten at subpixel precision for glyphs up to 256 physical px/em, + // covering the current 1080p and 4K typography with the same geometry. + geometry::tessellate(&outline.builder.build(), 0.15 / 256., FillRule::NonZero) + } +} + +struct GlyphOutline { + builder: Builder, + scale: f32, + open: bool, +} + +impl OutlineBuilder for GlyphOutline { + fn move_to(&mut self, x: f32, y: f32) { + if self.open { + self.builder.end(true); + } + self.builder.begin(point(x * self.scale, -y * self.scale)); + self.open = true; + } + fn line_to(&mut self, x: f32, y: f32) { + self.builder.line_to(point(x * self.scale, -y * self.scale)); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.builder.quadratic_bezier_to( + point(x1 * self.scale, -y1 * self.scale), + point(x * self.scale, -y * self.scale), + ); + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.builder.cubic_bezier_to( + point(x1 * self.scale, -y1 * self.scale), + point(x2 * self.scale, -y2 * self.scale), + point(x * self.scale, -y * self.scale), + ); + } + fn close(&mut self) { + if self.open { + self.builder.end(true); + self.open = false; + } + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..318ac1b --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,403 @@ +//! Bundled English, German, Spanish, Chinese, Brazilian Portuguese and Italian message catalog. +use serde::{Deserialize, Deserializer, Serialize}; +use std::{collections::BTreeMap, sync::OnceLock}; +use time::OffsetDateTime; + +type Messages = BTreeMap>; +fn messages() -> &'static Messages { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| { + serde_json::from_str(include_str!("../assets/messages.json")) + .expect("Bundled translations must be valid") + }) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + De, + Zh, + Es, + Pt, + It, +} +impl Language { + pub const ALL: [Self; 6] = [Self::En, Self::De, Self::Zh, Self::Es, Self::Pt, Self::It]; + pub fn code(self) -> &'static str { + match self { + Self::En => "en", + Self::De => "de", + Self::Zh => "zh", + Self::Es => "es", + Self::Pt => "pt", + Self::It => "it", + } + } + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::De => "Deutsch", + Self::Zh => "简体中文", + Self::Es => "Español", + Self::Pt => "Português (Brasil)", + Self::It => "Italiano", + } + } + pub fn index(self) -> usize { + match self { + Self::En => 0, + Self::De => 1, + Self::Zh => 2, + Self::Es => 3, + Self::Pt => 4, + Self::It => 5, + } + } + pub fn t(self, key: &str) -> &str { + messages() + .get(key) + .and_then(|entry| entry.get(self.code()).or_else(|| entry.get("en"))) + .map(String::as_str) + .unwrap_or(key) + } + pub fn format(self, key: &str, values: &[(&str, &str)]) -> String { + let mut value = self.t(key).to_owned(); + for (name, replacement) in values { + value = value.replace(&format!("{{{name}}}"), replacement); + } + value + } + pub fn category(self, value: &str) -> &str { + match value { + "Streaming" => self.t("categoryStreaming"), + "Music" => self.t("categoryMusic"), + "Gaming" => self.t("categoryGaming"), + "Utilities" => self.t("categoryUtilities"), + _ => self.t("categoryUtilities"), + } + } + pub fn wallpaper(self, id: &str) -> &str { + self.t(match id { + "dunes" => "monumentValley", + "san-francisco" => "sanFrancisco", + "northern-lights" => "northernLights", + _ => id, + }) + } + pub fn greeting(self, hour: u8, name: &str) -> String { + let key = match hour { + 0..=4 => "greetingNight", + 5..=11 => "morning", + 12..=17 => "afternoon", + _ => "evening", + }; + let name = name.trim(); + if name.is_empty() { + self.t(key).to_owned() + } else { + self.format(&format!("{key}Named"), &[("name", name)]) + } + } + pub fn date(self, now: OffsetDateTime) -> String { + let weekday = now.weekday().number_days_from_monday() as usize; + let month = now.month() as usize - 1; + match self { + Self::En => format!( + "{}, {} {}", + [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ][weekday], + [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ][month], + now.day() + ) + .to_uppercase(), + Self::De => format!( + "{}, {}. {}", + [ + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + "Sonntag" + ][weekday], + now.day(), + [ + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember" + ][month] + ) + .to_uppercase(), + Self::Zh => format!( + "{}月{}日星期{}", + now.month() as u8, + now.day(), + ["一", "二", "三", "四", "五", "六", "日"][weekday] + ), + Self::Es => format!( + "{}, {} de {}", + [ + "lunes", + "martes", + "miércoles", + "jueves", + "viernes", + "sábado", + "domingo" + ][weekday], + now.day(), + [ + "enero", + "febrero", + "marzo", + "abril", + "mayo", + "junio", + "julio", + "agosto", + "septiembre", + "octubre", + "noviembre", + "diciembre" + ][month] + ) + .to_uppercase(), + Self::Pt => format!( + "{}, {} de {}", + [ + "segunda-feira", + "terça-feira", + "quarta-feira", + "quinta-feira", + "sexta-feira", + "sábado", + "domingo" + ][weekday], + now.day(), + [ + "janeiro", + "fevereiro", + "março", + "abril", + "maio", + "junho", + "julho", + "agosto", + "setembro", + "outubro", + "novembro", + "dezembro" + ][month] + ) + .to_uppercase(), + Self::It => format!( + "{} {} {}", + [ + "lunedì", + "martedì", + "mercoledì", + "giovedì", + "venerdì", + "sabato", + "domenica" + ][weekday], + now.day(), + [ + "gennaio", + "febbraio", + "marzo", + "aprile", + "maggio", + "giugno", + "luglio", + "agosto", + "settembre", + "ottobre", + "novembre", + "dicembre" + ][month] + ) + .to_uppercase(), + } + } + pub fn clock(self, now: OffsetDateTime, hour24: bool, seconds: bool) -> String { + let base = self.clock_base(now, hour24); + if !seconds { + return base; + } + let tail = format!(":{:02}", now.second()); + match base.find(' ') { + Some(space) => format!("{}{tail}{}", &base[..space], &base[space..]), + None => format!("{base}{tail}"), + } + } + fn clock_base(self, now: OffsetDateTime, hour24: bool) -> String { + if hour24 { + return if self == Self::En { + format!("{:02}:{:02}", now.hour(), now.minute()) + } else { + format!("{}:{:02}", now.hour(), now.minute()) + }; + } + let hour = match now.hour() % 12 { + 0 => 12, + value => value, + }; + if self == Self::Zh { + format!( + "{}{hour}:{:02}", + if now.hour() >= 12 { "下午" } else { "上午" }, + now.minute() + ) + } else { + format!( + "{hour}:{:02} {}", + now.minute(), + if now.hour() >= 12 { "PM" } else { "AM" } + ) + } + } +} +pub fn deserialize_language<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result { + Ok( + match serde_json::Value::deserialize(deserializer)?.as_str() { + Some("de") => Language::De, + Some("zh") => Language::Zh, + Some("es") => Language::Es, + Some("pt") => Language::Pt, + Some("it") => Language::It, + _ => Language::En, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn every_message_has_all_languages_and_no_empty_values() { + for (key, entry) in messages() { + for language in Language::ALL { + assert!( + !entry + .get(language.code()) + .unwrap_or_else(|| panic!("Missing {key} / {}", language.code())) + .is_empty() + ); + } + } + } + #[test] + fn every_translation_preserves_the_english_message_placeholders() { + fn placeholders(message: &str) -> std::collections::BTreeSet<&str> { + message + .split('{') + .skip(1) + .map(|part| { + part.split_once('}') + .expect("Unclosed message placeholder") + .0 + }) + .collect() + } + + for (key, entry) in messages() { + let expected = placeholders(&entry["en"]); + for language in Language::ALL { + assert_eq!( + placeholders(&entry[language.code()]), + expected, + "Mismatched placeholders in {key} / {}", + language.code() + ); + } + } + } + #[test] + fn localization_keeps_user_provided_names_intact() { + assert_eq!( + Language::De.format("favoriteAdded", &[("app", "Netflix")]), + "Netflix zu Favoriten hinzugefügt." + ); + assert_eq!( + Language::Es.format("favoriteAdded", &[("app", "Netflix")]), + "Netflix añadida a favoritos." + ); + assert_eq!( + Language::Pt.format("favoriteAdded", &[("app", "Netflix")]), + "Netflix adicionado aos favoritos." + ); + assert_eq!( + Language::Zh.format("hidden", &[("count", "3")]), + "已隐藏(3)" + ); + assert_eq!(Language::De.category("Gaming"), "Spiele"); + assert_eq!(Language::En.greeting(15, ""), "Good afternoon."); + assert_eq!(Language::En.greeting(15, " Ana "), "Good afternoon, Ana."); + assert_eq!(Language::Es.greeting(2, "Ana"), "¿Aún despiertos, Ana?"); + assert_eq!(Language::Zh.greeting(20, "小明"), "小明,晚上好。"); + } + #[test] + fn clock_and_date_follow_the_selected_locale() { + let now = OffsetDateTime::parse( + "2026-10-21T20:41:00+02:00", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(); + assert_eq!(Language::De.date(now), "MITTWOCH, 21. OKTOBER"); + assert_eq!(Language::Es.date(now), "MIÉRCOLES, 21 DE OCTUBRE"); + assert_eq!(Language::Pt.date(now), "QUARTA-FEIRA, 21 DE OUTUBRO"); + assert_eq!(Language::Zh.date(now), "10月21日星期三"); + assert_eq!(Language::Zh.clock(now, false, false), "下午8:41"); + assert_eq!(Language::Zh.clock(now, false, true), "下午8:41:00"); + assert_eq!(Language::En.clock(now, true, false), "20:41"); + assert_eq!(Language::En.clock(now, true, true), "20:41:00"); + assert_eq!(Language::En.clock(now, false, true), "8:41:00 PM"); + } + #[test] + fn portuguese_dates_include_accented_names_and_handle_leap_days() { + for (date, expected) in [ + ("2024-02-29T00:05:00-03:00", "QUINTA-FEIRA, 29 DE FEVEREIRO"), + ("2026-03-03T12:00:00-03:00", "TERÇA-FEIRA, 3 DE MARÇO"), + ("2026-10-24T20:41:00-03:00", "SÁBADO, 24 DE OUTUBRO"), + ] { + let now = OffsetDateTime::parse(date, &time::format_description::well_known::Rfc3339) + .unwrap(); + assert_eq!(Language::Pt.date(now), expected); + } + } +} diff --git a/src/luna.rs b/src/luna.rs new file mode 100644 index 0000000..9c65463 --- /dev/null +++ b/src/luna.rs @@ -0,0 +1,210 @@ +//! One bounded LS2 round trip on the platform worker's own GLib context. +//! The hub sees the installed native executable. LSCallFromApplication (the +//! luna-send-pub -a path) instead requires an impersonation privilege we lack. +use libc::{c_char, c_int, c_ulong, c_void}; +use serde_json::Value; +use std::{ + ffi::{CStr, CString}, + ptr, thread, + time::{Duration, Instant}, +}; + +const MAX_REPLY: usize = 2 * 1024 * 1024; + +// Layout from the NDK's public luna-service2/lunaservice.h. +#[repr(C)] +struct Error { + code: c_int, + message: *mut c_char, + file: *const c_char, + line: c_int, + function: *const c_char, + padding: *mut c_void, + magic: c_ulong, +} + +#[link(name = "luna-service2")] +unsafe extern "C" { + fn LSErrorInit(error: *mut Error) -> bool; + fn LSErrorFree(error: *mut Error); + fn LSRegister(name: *const c_char, handle: *mut *mut c_void, error: *mut Error) -> bool; + fn LSUnregister(handle: *mut c_void, error: *mut Error) -> bool; + fn LSGmainContextAttach(handle: *mut c_void, context: *mut c_void, error: *mut Error) -> bool; + fn LSCallOneReply( + handle: *mut c_void, + uri: *const c_char, + payload: *const c_char, + callback: extern "C" fn(*mut c_void, *mut c_void, *mut c_void) -> bool, + userdata: *mut c_void, + token: *mut c_ulong, + error: *mut Error, + ) -> bool; + fn LSCallCancel(handle: *mut c_void, token: c_ulong, error: *mut Error) -> bool; + fn LSMessageGetPayload(message: *mut c_void) -> *const c_char; +} + +#[link(name = "glib-2.0")] +unsafe extern "C" { + fn g_main_context_new() -> *mut c_void; + fn g_main_context_iteration(context: *mut c_void, may_block: c_int) -> c_int; + fn g_main_context_unref(context: *mut c_void); +} + +impl Error { + fn new() -> Self { + // SAFETY: the public error structure is zero-initializable; LS2 then + // initializes its cookie before any other error API uses it. + unsafe { + let mut error = std::mem::zeroed(); + LSErrorInit(&mut error); + error + } + } + + fn describe(&self, stage: &str) -> String { + let detail = if self.message.is_null() { + String::new() + } else { + // SAFETY: LS2 owns this NUL-terminated string until LSErrorFree. + unsafe { CStr::from_ptr(self.message) } + .to_string_lossy() + .chars() + .take(240) + .collect() + }; + format!("LS2 {stage} failed ({}): {detail}", self.code) + } +} + +impl Drop for Error { + fn drop(&mut self) { + // SAFETY: every Error was initialized and is freed exactly once. + unsafe { LSErrorFree(self) }; + } +} + +#[derive(Default)] +struct Reply { + result: Option>, +} + +extern "C" fn receive(_handle: *mut c_void, message: *mut c_void, userdata: *mut c_void) -> bool { + if message.is_null() || userdata.is_null() { + return true; + } + // SAFETY: LS2 invokes this only while this connection's context is iterated + // on the worker. The heap slot outlives cancellation/unregistration. + unsafe { + let reply = &mut *userdata.cast::(); + let payload = LSMessageGetPayload(message); + reply.result = Some(if payload.is_null() { + Err("Luna reply has no payload".into()) + } else { + let size = libc::strnlen(payload, MAX_REPLY + 1); + if size > MAX_REPLY { + Err("TV response is too large".into()) + } else { + let bytes = std::slice::from_raw_parts(payload.cast::(), size); + serde_json::from_slice(bytes).map_err(|error| format!("Invalid Luna JSON: {error}")) + } + }); + } + true +} + +struct Connection { + handle: *mut c_void, + context: *mut c_void, + reply: Option>, +} + +impl Connection { + fn open() -> Result { + let mut error = Error::new(); + let mut connection = Self { + handle: ptr::null_mut(), + // SAFETY: GLib returns a new context owned by this connection. + context: unsafe { g_main_context_new() }, + reply: None, + }; + if connection.context.is_null() { + return Err("Could not allocate a Luna event context".into()); + } + // Anonymous registration still authenticates the executable and its LS2 + // role, and avoids colliding with SDL's own app-named registration. + // SAFETY: output/error pointers are valid; no fake application ID is sent. + if !unsafe { LSRegister(ptr::null(), &mut connection.handle, &mut error) } { + return Err(error.describe("registration")); + } + // SAFETY: the registered handle and private context stay alive together. + if !unsafe { LSGmainContextAttach(connection.handle, connection.context, &mut error) } { + return Err(error.describe("context attachment")); + } + Ok(connection) + } + + fn request(&mut self, uri: &str, payload: &Value) -> Result { + let uri = CString::new(uri).map_err(|_| "Invalid Luna URI")?; + let payload = CString::new(payload.to_string()).map_err(|_| "Invalid Luna payload")?; + let mut error = Error::new(); + let mut reply = Box::::default(); + let userdata = ptr::from_mut(reply.as_mut()).cast(); + self.reply = Some(reply); + let mut token = 0; + // SAFETY: strings and the stable reply allocation outlive this request. + if !unsafe { + LSCallOneReply( + self.handle, + uri.as_ptr(), + payload.as_ptr(), + receive, + userdata, + &mut token, + &mut error, + ) + } { + return Err(error.describe("call submission")); + } + let deadline = Instant::now() + Duration::from_millis(6500); + while Instant::now() < deadline { + // SAFETY: only this worker drives this private context. Iteration is + // nonblocking, so a missing service cannot hold the UI or this loop. + unsafe { g_main_context_iteration(self.context, 0) }; + if let Some(result) = self.reply.as_mut().and_then(|reply| reply.result.take()) { + return result; + } + thread::sleep(Duration::from_millis(2)); + } + let mut cancel_error = Error::new(); + // SAFETY: handle/token belong to this live request. Even a refused cancel + // leaves its callback allocation alive until unregistering on drop. + if !unsafe { LSCallCancel(self.handle, token, &mut cancel_error) } { + eprintln!("{}", cancel_error.describe("timeout cancellation")); + } + Err("The TV service timed out".into()) + } +} + +impl Drop for Connection { + fn drop(&mut self) { + if !self.handle.is_null() { + let mut error = Error::new(); + // SAFETY: unregister before freeing the context or callback storage. + if !unsafe { LSUnregister(self.handle, &mut error) } { + eprintln!("{}", error.describe("unregistration")); + // A refused unregister can retain the callback. Preserve its + // context and heap slot rather than leave dangling pointers. + std::mem::forget(self.reply.take()); + return; + } + } + if !self.context.is_null() { + // SAFETY: no live LS2 connection can now dispatch on this context. + unsafe { g_main_context_unref(self.context) }; + } + } +} + +pub fn call(uri: &str, payload: &Value) -> Result { + Connection::open()?.request(uri, payload) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..96ebac4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1129 @@ +mod app_artwork; +mod branding; +mod diagnostics; +mod font; +mod i18n; +#[cfg(all(feature = "webos", target_os = "linux", target_arch = "arm"))] +mod luna; +mod model; +mod platform; +mod raster; +mod renderer; +#[path = "scroll-preview.rs"] +mod scroll_preview; +mod state; +mod storage; +mod vector; +mod view; +mod weather; +#[path = "weather-service.rs"] +mod weather_service; +#[cfg(all(feature = "webos", any(target_os = "linux", test)))] +#[path = "webos-compat.rs"] +mod webos_compat; + +#[cfg(test)] +mod tests; + +use platform::{Platform, Request, Response}; +use raster::Size; +use sdl2::{ + event::{Event, WindowEvent}, + keyboard::Keycode, + mouse::MouseButton, + video::GLProfile, +}; +use state::{Direction, Effect, Overlay, State, Target}; +use std::{ + collections::VecDeque, + path::PathBuf, + time::{Duration, Instant}, +}; +use time::OffsetDateTime; + +#[derive(Default)] +struct Options { + webos_home: bool, + assets: Option, + preferences: Option, + screenshot: Option, + dump: Option, + frames: Option, + script: VecDeque, + fullscreen: bool, + time: Option, + resolution: Option, + capture_size: Option, + diagnostics: Option, + profile_frames: Option, + weather_test_server: Option, + test_extra_apps: usize, +} + +impl Options { + fn parse() -> Result { + let mut result = Self::default(); + let mut args = std::env::args().skip(1).peekable(); + // SAM/jailer puts native launch metadata (e.g. @system_native_app and + // nid) in argv[1]. It is not a Luma CLI option. Keep the original argv + // intact for SDL/libhelpers, which also read the process launch data. + if cfg!(feature = "webos") + && args.peek().is_some_and(|argument| { + serde_json::from_str::(argument) + .is_ok_and(|value| value.is_object()) + }) + { + if let Some(argument) = args.next() { + let metadata: serde_json::Value = + serde_json::from_str(&argument).map_err(|error| error.to_string())?; + result.webos_home = metadata.get("nid").and_then(serde_json::Value::as_str) + == Some(platform::HOME_ID); + } + eprintln!("Luma webOS launch parameters accepted"); + } + while let Some(arg) = args.next() { + if arg == "--help" { + println!( + "Luma native\n\n --assets PATH Generated artwork directory\n --preferences PATH Isolated preferences file\n --fullscreen Fullscreen desktop preview\n --resolution SIZE Request 1080p, 4k, or WIDTHxHEIGHT; verify actual drawable\n --frames N Exit after N event-loop frames\n --profile-frames N Force N presentations (20+) and report pacing after warmup\n --script ACTIONS Comma-separated home/apps/ha/settings/up/down/left/right/enter/back/menu/hold/type:TEXT/click:X:Y/wait\n --time RFC3339 Fix the preview's clock and date\n --screenshot PATH Save an offscreen PNG\n --capture-scale N Capture at 1x/1080p (default) or 2x/4k\n --diagnostics PATH Save actual drawable, GPU, font-cache and timing information\n --dump-state PATH Save final UI state as JSON\n\nCompile with --features webos for the TV backend. Desktop builds never call TV services." + ); + println!( + "\n --test-extra-apps N Explicit scroll preview with up to 300 non-launchable extra cards; separate preferences" + ); + std::process::exit(0); + } + if arg == "--fullscreen" { + result.fullscreen = true; + continue; + } + let value = args + .next() + .ok_or_else(|| format!("Missing value for {arg}"))?; + match arg.as_str() { + "--assets" => result.assets = Some(value.into()), + "--preferences" => result.preferences = Some(value.into()), + "--screenshot" => result.screenshot = Some(value.into()), + "--dump-state" => result.dump = Some(value.into()), + "--resolution" => result.resolution = Some(value.parse()?), + "--capture-scale" => { + result.capture_size = Some(match value.as_str() { + "1" => Size::HD, + "2" => Size::UHD, + _ => return Err("--capture-scale must be 1 or 2".into()), + }) + } + "--diagnostics" => result.diagnostics = Some(value.into()), + "--weather-test-server" => result.weather_test_server = Some(value), + "--test-extra-apps" => { + result.test_extra_apps = value + .parse::() + .ok() + .filter(|count| *count <= 300) + .ok_or("--test-extra-apps must be between 0 and 300")?; + } + "--profile-frames" => { + result.profile_frames = Some( + value + .parse::() + .ok() + .filter(|n| (20..=1000000).contains(n)) + .ok_or("--profile-frames must be between 20 and 1000000")?, + ) + } + "--time" => { + result.time = Some( + OffsetDateTime::parse( + &value, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|e| e.to_string())?, + ) + } + "--frames" => { + result.frames = Some( + value + .parse::() + .ok() + .filter(|n| *n > 0) + .ok_or("--frames must be positive")?, + ) + } + "--script" => result.script = value.split(',').map(str::to_owned).collect(), + _ => return Err(format!("Unknown option: {arg}")), + } + } + if result.screenshot.is_some() && result.frames.is_none() && result.profile_frames.is_none() + { + result.frames = Some(90); + } + Ok(result) + } +} + +struct Press { + target: Target, + start: Instant, + held: bool, + pointer: bool, +} + +const SCRIPT_POINTER: u32 = u32::MAX - 1; +// SDL-webOS's Back scancode is outside rust-sdl2's standard Scancode enum. +// Keycode retains the raw value: SDL_SCANCODE_TO_KEYCODE(SDL_SCANCODE_WEBOS_BACK). +const WEBOS_BACK_KEYCODE: i32 = (1 << 30) | 482; + +fn effects( + effects: Vec, + state: &mut State, + platform: &Platform, + preferences: &std::path::Path, + weather: &weather_service::Service, +) -> bool { + for effect in effects { + match effect { + Effect::Save => { + if let Err(error) = storage::save(preferences, &state.preferences) { + eprintln!("{error}"); + state.notify(state.preferences.language.t("savedError")); + } else { + platform.sync_splash(&state.preferences.background); + } + } + effect @ (Effect::Refresh | Effect::Reload) => { + state.reload_artwork |= effect == Effect::Reload; + if !state.loading { + state.loading = true; + state.dirty = true; + platform.send(Request::Refresh); + } + } + Effect::Launch(id) => { + if scroll_preview::is_placeholder(&id) { + state.notify(state.preferences.language.t("scrollTestOnly")); + continue; + } + if !state.launching { + if platform.webos { + state.launching = true; + state.notify(state.preferences.language.t("opening")); + platform.send(Request::Launch(id)); + } else { + let title = state + .inventory + .apps + .iter() + .find(|a| a.id == id) + .map_or(id.as_str(), |app| app.title.as_str()); + state.notify( + state + .preferences + .language + .format("desktopPreview", &[("app", title)]), + ); + } + } + } + Effect::Quit => return false, + Effect::Weather(command) => weather.send(command), + } + } + true +} + +fn script( + action: &str, + state: &mut State, + controls: &[state::Control], +) -> Result, String> { + Ok(match action { + "home" => { + state.open(0); + vec![] + } + "apps" => { + state.open(1); + vec![] + } + "ha" => { + state.open(2); + vec![] + } + "settings" => { + state.open(3); + vec![] + } + "up" => state.direction(Direction::Up, controls), + "down" => state.direction(Direction::Down, controls), + "left" => state.direction(Direction::Left, controls), + "right" => state.direction(Direction::Right, controls), + "enter" => state.activate(), + "back" => state.back(), + "menu" => { + state.options(); + vec![] + } + "hold" => { + state.hold(); + vec![] + } + "wait" => vec![], + action if action.starts_with("type:") => { + let field = if state.text_input_active() { + state.keyboard_field + } else { + match state.subpage { + Some(state::Subpage::Greeting) => state::TextField::Name, + Some(state::Subpage::WeatherLocation) => state::TextField::Location, + Some(state::Subpage::Input(_)) => state::TextField::InputName, + _ => state::TextField::Apps, + } + }; + if !state.text_input_active() { + state.open_keyboard(field); + } + state.text(&action[5..]); + vec![] + } + "await-search" => vec![], + "await-weather" => vec![], + _ => return Err(format!("Unknown scripted action: {action}")), + }) +} + +fn main() { + if let Err(error) = run() { + eprintln!("Luma native: {error}"); + std::process::exit(1); + } +} + +fn compose( + state: &mut State, + assets: &view::Assets, + pill: f32, + width: f32, + now: OffsetDateTime, +) -> view::Scene { + let scene = view::scene_with_pill(state, assets, pill, width, now); + if !state.pointer && state.overlay.is_none() && state.reveal_focus(&scene.controls) { + view::scene_with_pill(state, assets, pill, width, now) + } else { + scene + } +} + +fn weather_tick(state: &mut State, service: &weather_service::Service, visible: bool) { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let (command, changed) = state.weather.sync( + &state.preferences, + visible && state.page == state::Page::Home, + now, + ); + state.dirty |= changed; + service.generations(state.weather.generations()); + if let Some(command) = command { + service.send(command); + } + while let Ok(response) = service.receiver.try_recv() { + match response { + weather_service::Response::Search { id, result } => { + state.dirty |= state.weather.accept_search(id, result) + } + weather_service::Response::Forecast { id, key, result } => { + let (changed, command) = state.weather.accept_forecast(id, &key, result, now); + state.dirty |= changed; + if let Some(command) = command { + service.send(command); + } + } + } + } +} + +fn scripted_click( + sdl: &sdl2::Sdl, + window: &sdl2::video::Window, + position: &str, +) -> Result<(), String> { + let coordinates: Vec = position + .split(':') + .map(|value| value.parse::().map_err(|e| e.to_string())) + .collect::>()?; + let [x, y] = coordinates.as_slice() else { + return Err("click: expects logical x:y coordinates".into()); + }; + if !x.is_finite() || !y.is_finite() { + return Err("Pointer coordinates must be finite".into()); + } + let (w, h) = window.size(); + let scale = (w as f32 / 1920.).min(h as f32 / 1080.); + let x = (x * scale + (w as f32 - 1920. * scale) / 2.).round() as i32; + let y = (y * scale + (h as f32 - 1080. * scale) / 2.).round() as i32; + let events = sdl.event()?; + for event in [ + Event::MouseButtonDown { + timestamp: 0, + window_id: window.id(), + which: SCRIPT_POINTER, + mouse_btn: MouseButton::Left, + clicks: 1, + x, + y, + }, + Event::MouseButtonUp { + timestamp: 0, + window_id: window.id(), + which: SCRIPT_POINTER, + mouse_btn: MouseButton::Left, + clicks: 1, + x, + y, + }, + ] { + events.push_event(event)?; + } + Ok(()) +} + +fn run() -> Result<(), String> { + let startup = Instant::now(); + let mut options = Options::parse()?; + let webos = cfg!(feature = "webos"); + let startup_stage = |stage: &str| { + if webos { + eprintln!( + "Luma startup: {stage} after {} ms", + startup.elapsed().as_millis() + ); + } + }; + if !webos && std::path::Path::new("/etc/starfish-release").exists() { + return Err("This desktop build cannot run on a TV. Build with --features webos.".into()); + } + if webos { + if !options.webos_home && platform::redirect_to_home()? { + return Ok(()); + } + let surface_id = if options.webos_home { + platform::HOME_ID + } else { + platform::APP_ID + }; + // SDL-webOS reads APPID for its shell surface and getenv (not a hint) + // for WMCLASS. Set these before video init and before starting workers. + // SAFETY: startup is still single-threaded here. + unsafe { + std::env::set_var("APPID", surface_id); + std::env::set_var("SDL_VIDEO_WAYLAND_WMCLASS", surface_id); + } + eprintln!("Luma webOS surface: {surface_id}"); + sdl2::hint::set("SDL_WEBOS_ACCESS_POLICY_KEYS_BACK", "true"); + } + // Resolve the local offset before artwork, HTTP or platform workers start. + let local_offset = OffsetDateTime::now_local() + .map(|now| now.offset()) + .unwrap_or(time::UtcOffset::UTC); + let root = options.assets.clone().unwrap_or_else(|| { + if webos { + std::env::current_exe() + .unwrap_or_default() + .with_file_name("assets") + } else { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".build/assets") + } + }); + let assets = view::Assets::load(root, webos)?; + startup_stage("asset metadata"); + let default_preferences = options + .preferences + .clone() + .map(Ok) + .unwrap_or_else(|| storage::default_path(webos))?; + let preferences_path = if options.test_extra_apps > 0 && options.preferences.is_none() { + default_preferences.with_file_name("scroll-preview-preferences.json") + } else { + default_preferences.clone() + }; + // Start the preview with the user's current choices, but keep test favorites + // and hidden placeholders out of their normal preferences file. + let initial_preferences = if options.test_extra_apps > 0 + && options.preferences.is_none() + && !preferences_path.exists() + { + storage::load(&default_preferences)? + } else { + storage::load(&preferences_path)? + }; + let mut state = State::new(initial_preferences); + if options.test_extra_apps > 0 { + eprintln!( + "Luma scroll preview: explicit {}-app expansion; preferences {}", + options.test_extra_apps, + preferences_path.display() + ); + } + if webos && options.weather_test_server.is_some() { + return Err("Weather test servers are only available in desktop builds".into()); + } + let weather_cache = weather_service::cache_path(&preferences_path); + state.weather.cache = weather_service::load_cache(&weather_cache); + let weather_service = weather_service::Service::new( + weather_service::Client::new(options.weather_test_server.as_deref())?, + weather_cache, + ); + let platform = Platform::new( + webos, + options.preferences.is_none() && options.test_extra_apps == 0, + ); + effects( + vec![Effect::Refresh], + &mut state, + &platform, + &preferences_path, + &weather_service, + ); + platform.sync_splash(&state.preferences.background); + startup_stage("preferences and workers"); + + let sdl = sdl2::init()?; + let drivers: Vec<_> = sdl2::video::drivers().collect(); + if webos && !drivers.contains(&"wayland") { + return Err(format!( + "SDL has no Wayland backend (drivers: {}). Rebuild the package with the SDL-webOS runtime.", + drivers.join(", ") + )); + } + let video = sdl.video().map_err(|error| { + format!( + "SDL video initialization: {error}; drivers: {}; XDG_RUNTIME_DIR={:?}; WAYLAND_DISPLAY={:?}", + drivers.join(", "), + std::env::var_os("XDG_RUNTIME_DIR"), + std::env::var_os("WAYLAND_DISPLAY"), + ) + })?; + eprintln!("Luma SDL video driver: {}", video.current_video_driver()); + startup_stage("SDL video"); + let attributes = video.gl_attr(); + if webos { + attributes.set_context_profile(GLProfile::GLES); + attributes.set_context_version(2, 0); + } else { + attributes.set_context_profile(GLProfile::Core); + attributes.set_context_version(3, 2); + } + attributes.set_double_buffer(true); + attributes.set_alpha_size(8); + attributes.set_depth_size(0); + attributes.set_stencil_size(0); + let requested = options.resolution.unwrap_or(if webos { + Size::HD + } else { + Size::new(1280, 720) + }); + let create_window = || { + let mut builder = video.window("Luma", requested.width, requested.height); + builder.opengl().allow_highdpi().position_centered(); + if options.fullscreen || (webos && options.resolution.is_none()) { + builder.fullscreen_desktop(); + } else if webos { + builder.borderless(); + } else { + builder.resizable(); + } + let window = builder.build().map_err(|e| e.to_string())?; + let context = window.gl_create_context()?; + Ok::<_, String>((window, context)) + }; + let mut opened = None; + let mut failures = Vec::new(); + // Prefer true sample coverage of intact glyph outlines. Some TV EGL stacks + // have fewer configurations, so retain a usable non-MSAA fallback. + for samples in [4, 2, 0] { + attributes.set_multisample_buffers(u8::from(samples > 0)); + attributes.set_multisample_samples(samples); + match create_window() { + Ok(window) => { + opened = Some(window); + break; + } + Err(error) => failures.push(format!("{samples}x MSAA: {error}")), + } + } + let (window, _context) = opened.ok_or_else(|| failures.join("; "))?; + gl::load_with(|name| video.gl_get_proc_address(name) as *const _); + if !webos { + unsafe { gl::Enable(gl::MULTISAMPLE) }; + } + let mut samples = 0; + unsafe { gl::GetIntegerv(gl::SAMPLES, &mut samples) }; + eprintln!("Luma GL multisampling: {samples} samples"); + startup_stage("window and GL context"); + if let Err(error) = video.gl_set_swap_interval(1) { + eprintln!("VSync: {error}"); + } + let mut renderer = renderer::Renderer::new(&assets.root.join("font.ttf"), webos)?; + startup_stage("renderer"); + let mut profile = diagnostics::Profile::default(); + let mut presented = 0usize; + let mut captured = None; + let mut last_drawable = None; + let mut events = sdl.event_pump()?; + let mut press: Option = None; + let initial_nav = view::navigation(&assets, state.preferences.language); + let mut pill = initial_nav.items[0].x; + let mut pill_width = initial_nav.items[0].w; + let mut last_frame = Instant::now(); + let mut last_minute = None; + let mut frame = 0u64; + let mut visible = true; + let mut running = true; + let mut queued_event = None; + let mut text_active = false; + let mut initial_focus = options.script.is_empty(); + let scripted_input = !options.script.is_empty(); + let mut scene = compose( + &mut state, + &assets, + pill, + pill_width, + options.time.unwrap_or_else(OffsetDateTime::now_utc), + ); + while running { + let now = options + .time + .unwrap_or_else(|| OffsetDateTime::now_utc().to_offset(local_offset)); + // Redraw once per minute, or once per second while seconds are shown. + let clock_slot = if state.preferences.show_seconds { + now.unix_timestamp() + } else { + now.unix_timestamp() / 60 + }; + if last_minute != Some(clock_slot) { + last_minute = Some(clock_slot); + state.dirty = true; + } + state.tick(); + state.dirty |= assets.artwork.poll(); + while let Ok(response) = platform.receiver.try_recv() { + match response { + Response::Inventory(Ok((mut inventory, warning))) => { + if std::mem::take(&mut state.reload_artwork) { + assets.artwork.reset(); + } else { + assets.artwork.refresh(); + } + scroll_preview::append( + &mut inventory, + options.test_extra_apps, + state.preferences.language, + ); + state.set_inventory(inventory, warning); + if initial_focus + && state.focus == Target::Nav(0) + && state.page == state::Page::Home + { + state.focus = state.first_content(); + } + initial_focus = false; + } + Response::Inventory(Err(error)) => { + state.loading = false; + state.error = Some(error); + state.dirty = true; + if initial_focus { + state.focus = state.first_content(); + initial_focus = false; + } + } + Response::Launched(result) => { + state.launching = false; + match result { + Err(error) => { + eprintln!("{error}"); + state.notify(state.preferences.language.t("appOpenError")); + } + Ok(()) => { + state.notice = None; + state.dirty = true; + } + } + } + } + } + weather_tick(&mut state, &weather_service, visible); + // Rebuild hit regions before input whenever inventory/layout changed. + if state.dirty { + scene = compose(&mut state, &assets, pill, pill_width, now); + } + for event in queued_event.take().into_iter().chain(events.poll_iter()) { + // Window-entry pointer events can arrive after a slow first upload. + // Bounded scripted captures own input until their final frame; only + // their explicitly injected SDL clicks may alter pointer focus. + let script_owns_input = scripted_input + && (options.frames.is_some() + || options.profile_frames.is_some() + || !options.script.is_empty()); + let external_input = match &event { + Event::MouseButtonDown { which, .. } | Event::MouseButtonUp { which, .. } => { + *which != SCRIPT_POINTER + } + Event::MouseMotion { .. } + | Event::MouseWheel { .. } + | Event::KeyDown { .. } + | Event::KeyUp { .. } + | Event::TextEditing { .. } + | Event::TextInput { .. } => true, + _ => false, + }; + if script_owns_input && external_input { + continue; + } + let mut pending = vec![]; + match event { + Event::Quit { .. } => running = false, + Event::Window { + window_id, + win_event, + .. + } if window_id == window.id() => match win_event { + WindowEvent::Hidden | WindowEvent::Minimized => { + visible = false; + press = None; + if matches!(state.overlay, Some(Overlay::SystemKeyboard)) { + pending = state.finish_text_input(); + } + profile.pause(); + } + WindowEvent::FocusLost => press = None, + WindowEvent::Shown | WindowEvent::Restored | WindowEvent::FocusGained => { + visible = true; + state.dirty = true; + if webos { + pending.push(Effect::Refresh); + } + } + WindowEvent::Resized(..) + | WindowEvent::SizeChanged(..) + | WindowEvent::Exposed => state.dirty = true, + _ => {} + }, + Event::TextInput { text, .. } => state.text(&text), + Event::TextEditing { + text, + start, + length, + .. + } if text.is_empty() && matches!(state.overlay, Some(Overlay::SystemKeyboard)) => { + state.delete_surrounding_text(start, length); + } + Event::KeyDown { + keycode: Some(key), + repeat, + .. + } => match key { + Keycode::Up => pending = state.direction(Direction::Up, &scene.controls), + Keycode::Down => pending = state.direction(Direction::Down, &scene.controls), + Keycode::Left => pending = state.direction(Direction::Left, &scene.controls), + Keycode::Right => pending = state.direction(Direction::Right, &scene.controls), + Keycode::Return | Keycode::KpEnter + if matches!(state.overlay, Some(Overlay::SystemKeyboard)) => + { + press = None; + pending = state.finish_text_input(); + } + Keycode::Return | Keycode::KpEnter if !repeat => { + press = Some(Press { + target: state.focus.clone(), + start: Instant::now(), + held: false, + pointer: false, + }); + } + Keycode::Escape | Keycode::AcBack if !repeat => { + press = None; + pending = state.back(); + } + Keycode::Backspace if state.text_input_active() => state.delete(), + Keycode::Backspace if !repeat => { + press = None; + pending = state.back(); + } + Keycode::Clear if matches!(state.overlay, Some(Overlay::SystemKeyboard)) => { + let length = state.editing_text().len() as i32; + state.delete_surrounding_text(-length, length); + } + Keycode::M | Keycode::Menu if !repeat && state.overlay.is_none() => { + press = None; + state.options(); + } + Keycode::F5 if !repeat => pending.push(Effect::Reload), + key if !repeat + && (i32::from(key) == 461 + || (webos && i32::from(key) == WEBOS_BACK_KEYCODE)) => + { + press = None; + pending = state.back(); + } + _ => {} + }, + Event::KeyUp { + keycode: Some(Keycode::Return | Keycode::KpEnter), + .. + } => { + if let Some(held) = press.take() + && !held.pointer + && !held.held + && held.target == state.focus + { + pending = state.activate(); + } + } + Event::MouseMotion { x, y, .. } + if state.moving.is_none() + && !matches!(state.overlay, Some(Overlay::SystemKeyboard)) => + { + let hovered = hit(&scene.controls, &window, x, y, &state); + if let Some(target) = hovered.clone() { + state.pointer_focus(target); + } + if press + .as_ref() + .is_some_and(|p| p.pointer && hovered.as_ref() != Some(&p.target)) + { + press = None; + } + } + Event::MouseButtonDown { + mouse_btn, x, y, .. + } => { + if let Some(target) = hit(&scene.controls, &window, x, y, &state) + && state.moving.is_none() + { + if matches!(state.overlay, Some(Overlay::SystemKeyboard)) { + pending = state.finish_text_input(); + } + // Pointer-down must not normalize scroll and move the clicked control. + state.pointer_focus(target); + if mouse_btn == MouseButton::Right { + state.options(); + press = None; + } else if mouse_btn == MouseButton::Left { + press = Some(Press { + target: state.focus.clone(), + start: Instant::now(), + held: false, + pointer: true, + }); + } + } else if mouse_btn == MouseButton::Left { + press = None; + } + } + Event::MouseButtonUp { + mouse_btn: MouseButton::Left, + x, + y, + .. + } => match press.take() { + Some(held) + if held.pointer + && !held.held + && hit(&scene.controls, &window, x, y, &state).as_ref() + == Some(&held.target) => + { + pending = state.activate() + } + None if scene.modal.is_some_and(|rect| { + pointer_position(&window, x, y).is_some_and(|(x, y)| !rect.contains(x, y)) + }) => + { + pending = state.back() + } + _ => {} + }, + Event::MouseWheel { + x, + y, + mouse_x, + mouse_y, + .. + } if state.overlay.is_none() => { + let over_favorites = state.page == state::Page::Home + && pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| { + (64. ..1856.).contains(&x) + && scene.controls.iter().any(|control| { + matches!(control.target, Target::App(_)) + && control.rect.contains(x, y) + }) + }); + if state.page == state::Page::Home && (x != 0 || over_favorites) { + state.scroll_favorites( + (if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH, + ); + press = None; + } else { + let max = scene + .controls + .iter() + .filter(|c| c.scrollable) + .map(|c| { + c.rect.y + c.rect.h + state.scroll + - if state.page == state::Page::Home { + 1041.6 + } else { + 982.4 + } + }) + .fold(0., f32::max); + state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.)); + } + state.pointer = true; + state.dirty = true; + } + _ => {} + } + running &= effects( + pending, + &mut state, + &platform, + &preferences_path, + &weather_service, + ); + if press + .as_ref() + .is_some_and(|p| !p.held && p.target != state.focus) + { + press = None; + } + let pressed = press.as_ref().filter(|p| !p.held).map(|p| p.target.clone()); + if state.pressed != pressed { + state.pressed = pressed; + state.dirty = true; + } + // SDL may deliver several navigation events in a single poll. + if state.dirty { + scene = compose(&mut state, &assets, pill, pill_width, now); + } + } + if let Some(held) = &mut press + && !held.held + && held.start.elapsed() >= Duration::from_millis(600) + { + held.held = true; + state.pressed = None; + state.hold(); + } + if !state.loading + && frame.is_multiple_of(3) + && !options.script.front().is_some_and(|action| { + (action == "await-search" && state.weather.searching) + || (action == "await-weather" && state.weather.fetching()) + }) + && let Some(action) = options.script.pop_front() + { + if let Some(position) = action.strip_prefix("click:") { + scripted_click(&sdl, &window, position)?; + } else { + let pending = script(&action, &mut state, &scene.controls)?; + running &= effects( + pending, + &mut state, + &platform, + &preferences_path, + &weather_service, + ); + } + } + weather_tick(&mut state, &weather_service, visible); + // SDL-webOS disables text input when its own panel is dismissed, even + // if it did not forward a Back/Return key to the application. + if text_active + && matches!(state.overlay, Some(Overlay::SystemKeyboard)) + && !video.text_input().is_active() + { + press = None; + let pending = state.finish_text_input(); + running &= effects( + pending, + &mut state, + &platform, + &preferences_path, + &weather_service, + ); + } + let keyboard = state.text_input_active(); + if keyboard != text_active { + if keyboard { + video.text_input().start(); + } else { + video.text_input().stop(); + } + text_active = keyboard; + } + let dt = last_frame.elapsed().as_secs_f32().min(0.1); + last_frame = Instant::now(); + let navigation = view::navigation(&assets, state.preferences.language); + let target = navigation.items[state.page_index()].x; + let target_width = navigation.items[state.page_index()].w; + if state.preferences.reduced_motion + || ((pill - target).abs() < 0.2 && (pill_width - target_width).abs() < 0.2) + { + pill = target; + pill_width = target_width; + } else { + pill += (target - pill) * (1. - (-20. * dt).exp()); + pill_width += (target_width - pill_width) * (1. - (-20. * dt).exp()); + state.dirty = true; + } + let final_frame = options.frames.is_some_and(|limit| frame + 1 >= limit) + || (visible + && options + .profile_frames + .is_some_and(|limit| presented + 1 >= limit)); + if ((state.dirty || options.profile_frames.is_some()) && visible) || final_frame { + scene = compose(&mut state, &assets, pill, pill_width, now); + let (width, height) = window.drawable_size(); + if width > 0 && height > 0 { + if final_frame && let Some(path) = &options.screenshot { + captured = Some(renderer.capture( + &scene.draw, + path, + options.capture_size.unwrap_or(Size::HD), + )?); + } + renderer.render(&scene.draw, width, height); + window.gl_swap_window(); + if presented == 0 { + startup_stage("first frame"); + if webos { + let stats = renderer.stats(); + eprintln!( + "Luma vector text: {} glyphs, {} text runs, {} KiB glyph geometry, {} KiB GPU text", + stats.cached_glyphs, + stats.cached_text_runs, + stats.glyph_geometry_bytes / 1024, + stats.text_geometry_bytes / 1024, + ); + } + } + presented += 1; + if options.profile_frames.is_some() { + profile.record( + Instant::now(), + renderer.stats().cpu_submission_ms, + final_frame && options.screenshot.is_some(), + ); + } + let drawable = Size::new(width, height); + if last_drawable != Some(drawable) { + if options.diagnostics.is_some() || options.resolution.is_some() { + eprintln!( + "Luma drawable: {}x{}, density {:.3}x, 4K buffer: {}", + width, + height, + drawable.scale(), + drawable.is_4k() + ); + } + last_drawable = Some(drawable); + } + } + state.dirty = false; + } + frame += 1; + if final_frame { + running = false; + } + // Block until input instead of polling at frame rate on idle screens. + // VSync paces animation; holds, scripted checks and icons still being + // decoded need short timed wakes. + let timeout = if !visible { + 250 + } else if pill != target + || pill_width != target_width + || state.animating() + || options.profile_frames.is_some() + { + 0 + } else if press.is_some() || options.frames.is_some() || assets.artwork.pending() { + 8 + } else { + 100 + }; + queued_event = events.wait_event_timeout(timeout); + } + if text_active { + video.text_input().stop(); + } + if !options.script.is_empty() { + return Err("Frame budget ended before the input script completed".into()); + } + if let Some(path) = options.dump { + let state = serde_json::json!({ "page": state.page, "focus": state.focus, "query": state.query, + "filter": state.filter, "preferences": state.preferences, "apps": state.apps().iter().map(|a| &a.id).collect::>(), + "error": state.error, "scroll": state.scroll, "home_scroll": state.home_scroll, "subpage": state.subpage, "modal": state.overlay.is_some(), "weather": state.weather, + "inventory": state.inventory.apps, + "ui_text": scene.draw.iter().filter_map(|draw| match draw { renderer::Draw::Text(value, ..) => Some(value), _ => None }).collect::>() }); + std::fs::write( + path, + serde_json::to_vec_pretty(&state).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + if let Some(path) = options.diagnostics { + let (width, height) = window.size(); + let (dw, dh) = window.drawable_size(); + let display_mode = window.display_index().ok().and_then(|index| video.current_display_mode(index).ok()) + .map(|mode| serde_json::json!({ "width": mode.w, "height": mode.h, "refresh_hz": mode.refresh_rate })); + let report = serde_json::json!({ + "logical_layout": Size::HD, "requested_window": requested, + "window": Size::new(width, height), "drawable": Size::new(dw, dh), + "display_mode": display_mode, + "drawable_is_4k": Size::new(dw, dh).is_4k(), "presented_frames": presented, + "graphics": renderer.info, "last_presented": renderer.stats(), "capture": captured, + "profile": options.profile_frames.map(|_| profile.summary()), + }); + std::fs::write( + path, + serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn pointer_position(window: &sdl2::video::Window, x: i32, y: i32) -> Option<(f32, f32)> { + let (width, height) = window.size(); + let scale = (width as f32 / 1920.).min(height as f32 / 1080.); + if scale <= 0. { + return None; + } + Some(( + (x as f32 - (width as f32 - 1920. * scale) / 2.) / scale, + (y as f32 - (height as f32 - 1080. * scale) / 2.) / scale, + )) +} + +fn hit( + controls: &[state::Control], + window: &sdl2::video::Window, + x: i32, + y: i32, + state: &State, +) -> Option { + let (x, y) = pointer_position(window, x, y)?; + controls + .iter() + .rev() + .find(|control| { + control.rect.contains(x, y) + && (!control.scrollable + || ((64. ..1856.).contains(&x) + && y >= 108.8 + && y < if state.page == state::Page::Home && state.moving.is_none() { + 1053.6 + } else { + 994.4 + })) + }) + .map(|control| control.target.clone()) +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..18c55ca --- /dev/null +++ b/src/model.rs @@ -0,0 +1,295 @@ +use crate::i18n::Language; +use crate::weather::{Location, Unit}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; + +pub const CATEGORIES: [&str; 4] = ["Streaming", "Music", "Gaming", "Utilities"]; +pub const BACKGROUNDS: [(&str, &str); 9] = [ + ("slate", "Slate"), + ("midnight", "Midnight"), + ("prism", "Prism"), + ("dunes", "Monument Valley"), + ("san-francisco", "San Francisco"), + ("summit", "Summit"), + ("forest", "Forest"), + ("yosemite", "Yosemite"), + ("northern-lights", "Northern lights"), +]; + +// Generic fallbacks use delimited words, so "game" does not match "endgame". +// Non-ASCII bytes count as boundaries for localized titles. Curated vector +// branding is selected separately by exact ID in branding.rs. +fn contains_word(haystack: &str, needle: &str) -> bool { + let bytes = haystack.as_bytes(); + haystack.match_indices(needle).any(|(start, matched)| { + let end = start + matched.len(); + let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric(); + let after_ok = end == bytes.len() || !bytes[end].is_ascii_alphanumeric(); + before_ok && after_ok + }) +} + +#[derive(Clone, Debug, Serialize)] +pub struct App { + pub id: String, + pub title: String, + pub brand: String, + pub category: String, + #[serde(skip)] + pub icon: Option, +} + +impl App { + pub fn new(id: &str, title: &str) -> Self { + let identity = format!("{id} {title}").to_lowercase(); + let generic = [ + ("kodi", "kodi", "Streaming"), + ("moonlight", "moonlight", "Gaming"), + ("limelight", "moonlight", "Gaming"), + ("game", "gaming", "Gaming"), + ("hbchannel", "homebrew", "Utilities"), + ] + .into_iter() + .find_map(|(needle, brand, category)| { + contains_word(&identity, needle).then_some((brand, category)) + }) + .unwrap_or(("generic", "Utilities")); + let (brand, category) = + crate::branding::for_app(id).map_or(generic, |brand| (brand.key, brand.category)); + Self { + id: id.into(), + title: title.into(), + brand: brand.into(), + category: category.into(), + icon: None, + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct Input { + pub id: String, + pub title: String, + pub port: String, + pub connected: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct Inventory { + pub apps: Vec, + pub inputs: Vec, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)] +pub enum InputMode { + #[default] + Auto, + On, + Off, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct Preferences { + pub favorites: Option>, + pub hidden: Vec, + pub background: String, + pub show_home_names: bool, + pub show_app_names: bool, + pub hour_24: bool, + pub reduced_motion: bool, + pub show_seconds: bool, + /// Optional name appended to the Home greeting ("Good evening, Ana."). Empty = plain greeting. + pub greeting_name: String, + pub input_modes: BTreeMap, + /// Custom names by input app ID; the TV's port and label stay visible as detail. + pub input_names: BTreeMap, + #[serde(deserialize_with = "crate::weather::deserialize_enabled")] + pub show_weather: bool, + #[serde(deserialize_with = "crate::weather::deserialize_location")] + pub weather_location: Option, + #[serde(deserialize_with = "crate::weather::deserialize_unit")] + pub temperature_unit: Unit, + #[serde(deserialize_with = "crate::i18n::deserialize_language")] + pub language: Language, +} + +impl Default for Preferences { + fn default() -> Self { + Self { + favorites: None, + hidden: vec![], + background: "dunes".into(), + show_home_names: false, + show_app_names: true, + hour_24: false, + reduced_motion: false, + show_seconds: false, + greeting_name: String::new(), + input_modes: BTreeMap::new(), + input_names: BTreeMap::new(), + show_weather: true, + weather_location: None, + temperature_unit: Unit::Celsius, + language: Language::En, + } + } +} + +impl Preferences { + pub fn favorites(&self, apps: &[App]) -> Vec { + self.favorites.clone().unwrap_or_else(|| { + let mut ids = Vec::new(); + for brand in [ + "netflix", + "youtube", + "apple-tv", + "disney-plus", + "prime-video", + "live-tv", + ] { + if let Some(app) = apps.iter().find(|app| app.brand == brand) { + ids.push(app.id.clone()); + } + } + for app in apps { + if ids.len() >= 6 { + break; + } + if !ids.contains(&app.id) { + ids.push(app.id.clone()); + } + } + ids + }) + } + + pub fn input_visible(&self, input: &Input) -> bool { + match self.input_modes.get(&input.id).copied().unwrap_or_default() { + InputMode::On => true, + InputMode::Off => false, + InputMode::Auto => input.connected == Some(true), + } + } + + pub fn input_name(&self, input: &Input) -> Option<&str> { + self.input_names.get(&input.id).map(String::as_str) + } + + /// The custom name if one is saved, otherwise the port ("HDMI 2"). + pub fn input_title<'a>(&'a self, input: &'a Input) -> &'a str { + self.input_name(input).unwrap_or(&input.port) + } + + /// Saves a cleaned name; an empty name restores the port title. + pub fn set_input_name(&mut self, id: &str, name: &str) -> bool { + if !valid_app_id(id) { + return false; + } + let name = clean_input_name(name); + if name.is_empty() { + self.input_names.remove(id).is_some() + } else { + self.input_names.insert(id.into(), name.clone()) != Some(name) + } + } + + pub fn sanitize(&mut self) { + if !BACKGROUNDS.iter().any(|(id, _)| *id == self.background) { + self.background = "dunes".into(); + } + fn clean(ids: &mut Vec) { + let mut seen = HashSet::new(); + ids.retain(|id| valid_app_id(id) && seen.insert(id.clone())); + ids.truncate(1000); + } + if let Some(ids) = &mut self.favorites { + clean(ids); + } + clean(&mut self.hidden); + self.input_modes.retain(|id, _| valid_app_id(id)); + self.input_names = std::mem::take(&mut self.input_names) + .into_iter() + .filter(|(id, _)| valid_app_id(id)) + .map(|(id, name)| (id, clean_input_name(&name))) + .filter(|(_, name)| !name.is_empty()) + .take(128) + .collect(); + } +} + +pub const INPUT_NAME_LIMIT: usize = 40; + +pub fn clean_input_name(name: &str) -> String { + let clean: String = name.chars().filter(|c| !c.is_control()).collect(); + let clean: String = clean.trim().chars().take(INPUT_NAME_LIMIT).collect(); + clean.trim_end().into() +} + +pub fn valid_app_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 128 + && id + .split(['.', '_', '-']) + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())) +} + +pub fn demo_inventory() -> Inventory { + let mut inventory = Inventory { + apps: [ + ("com.webos.app.livetv", "Live TV"), + ("netflix", "Netflix"), + ("youtube.leanback.v4", "YouTube"), + ("com.apple.appletv", "Apple TV"), + ("com.disney.disneyplus-prod", "Disney+"), + ("amazon", "Prime Video"), + ("plex", "Plex"), + ("spotify-beehive", "Spotify"), + ("org.jellyfin.webos", "Jellyfin"), + ("org.xbmc.kodi", "Kodi"), + ("com.limelight.webos", "Moonlight"), + ("org.webosbrew.hbchannel", "Homebrew Channel"), + ("com.webos.app.browser", "Web Browser"), + ("com.arte.tv", "ARTE"), + ("de.zdf.app.zdfm3", "ZDF"), + ("ard.mediathek", "Das Erste"), + ("coupangplay", "Coupang Play"), + ("com.frograms.watchaplay.webos", "Watcha Play"), + ("pooq", "Wavve"), + ("com.cmb.rainbowtv", "Rainbow TV"), + ("com.lgtvod.kr.app", "LG TV VOD"), + ("com.webos.app.igallery", "올레드 갤러리"), + ("com.webos.app.roomconnect", "룸투룸 셰어"), + ] + .into_iter() + .map(|(id, title)| App::new(id, title)) + .collect(), + inputs: vec![ + Input { + id: "com.webos.app.hdmi1".into(), + title: "PlayStation 5".into(), + port: "HDMI 1".into(), + connected: Some(true), + }, + Input { + id: "com.webos.app.hdmi2".into(), + title: "Apple TV".into(), + port: "HDMI 2".into(), + connected: Some(true), + }, + Input { + id: "com.webos.app.hdmi3".into(), + title: "HDMI 3".into(), + port: "HDMI 3".into(), + connected: Some(false), + }, + ], + }; + // Desktop-only artwork gallery. TV inventory is always read from services. + for brand in crate::branding::BRANDS { + if !inventory.apps.iter().any(|app| app.brand == brand.key) { + inventory.apps.push(App::new(brand.ids[0], brand.title)); + } + } + inventory +} diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..f8df930 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,425 @@ +//! Blocking platform work runs on one worker, never in the SDL/UI thread. +//! TV calls originate from this native executable's LS2 connection, not a CLI +//! process trying to impersonate its application identity. +use crate::{ + app_artwork, + model::{App, Input, Inventory, demo_inventory, valid_app_id}, +}; +use serde_json::{Value, json}; +use std::{collections::HashSet, sync::mpsc, thread}; + +pub const APP_ID: &str = "com.ecklf.luma"; +pub const HOME_ID: &str = "com.webos.app.home"; + +pub fn redirect_to_home() -> Result { + // The root-only integration mounts this marker with its native Home + // manifest. A normal tile launch then opens that same instance instead of + // starting a second renderer writing the same preferences. + if std::fs::read_to_string("/usr/palm/applications/com.webos.app.home/.luma-home") + .is_ok_and(|marker| marker.trim() == APP_ID) + { + thread::spawn(|| { + call( + "luna://com.webos.applicationManager/launch", + json!({"id": HOME_ID}), + ) + }) + .join() + .map_err(|_| "Home launch worker failed".to_string())??; + return Ok(true); + } + Ok(false) +} + +pub enum Request { + Refresh, + Launch(String), + SyncSplash(String), +} +pub enum Response { + Inventory(Result<(Inventory, Option), String>), + Launched(Result<(), String>), +} + +pub struct Platform { + sender: mpsc::Sender, + pub receiver: mpsc::Receiver, + pub webos: bool, + splash_enabled: bool, +} + +impl Platform { + pub fn new(webos: bool, splash_enabled: bool) -> Self { + let (sender, requests) = mpsc::channel(); + let (responses, receiver) = mpsc::channel(); + thread::spawn(move || { + let mut splash_background = None; + while let Ok(request) = requests.recv() { + let response = match request { + Request::Refresh => Response::Inventory(if webos { + inventory() + } else { + Ok((demo_inventory(), None)) + }), + Request::Launch(id) => Response::Launched(if webos { + if crate::scroll_preview::is_placeholder(&id) { + Err("Scroll preview cards cannot be launched".into()) + } else if !valid_app_id(&id) { + Err("Invalid application identifier".into()) + } else { + call( + "luna://com.webos.applicationManager/launch", + json!({"id": id}), + ) + .map(|_| ()) + } + } else { + Ok(()) + }), + Request::SyncSplash(background) => { + if webos + && splash_enabled + && splash_background.as_ref() != Some(&background) + { + match sync_splash(&background) { + Ok(()) => splash_background = Some(background), + Err(error) => eprintln!("Luma splash: {error}"), + } + } + continue; + } + }; + if responses.send(response).is_err() { + break; + } + } + }); + Self { + sender, + receiver, + webos, + splash_enabled, + } + } + + pub fn send(&self, request: Request) { + let _ = self.sender.send(request); + } + + pub fn sync_splash(&self, background: &str) { + if self.webos && self.splash_enabled { + self.send(Request::SyncSplash(background.to_owned())); + } + } +} + +fn sync_splash(background: &str) -> Result<(), String> { + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let directory = executable + .parent() + .ok_or("Missing installation directory")?; + let quote = |value: &str| format!("'{}'", value.replace('\'', "'\\''")); + let command = format!( + "/usr/bin/node -e {} {}", + quote(include_str!("../tv/sync-splash.cjs")), + quote(&json!({"directory": directory, "background": background}).to_string()) + ); + let envelope = call( + "luna://org.webosbrew.hbchannel.service/exec", + json!({"command": command}), + )?; + let saved = inventory_payload(&envelope, "background")?; + if saved.as_str() != Some(background) { + return Err("Splash background was not updated".into()); + } + Ok(()) +} + +fn call(uri: &str, payload: Value) -> Result { + #[cfg(all(feature = "webos", target_os = "linux", target_arch = "arm"))] + let response = crate::luna::call(uri, &payload); + #[cfg(not(all(feature = "webos", target_os = "linux", target_arch = "arm")))] + let response: Result = { + let _ = payload; + Err("Native Luna services are only available in an ARM webOS build".into()) + }; + response.and_then(checked_response).map_err(|error| { + let error = format!("{uri}: {error}"); + eprintln!("Luma TV service: {error}"); + error + }) +} + +fn checked_response(value: Value) -> Result { + if value.get("returnValue").and_then(Value::as_bool) != Some(true) { + return Err(value + .get("errorText") + .and_then(Value::as_str) + .unwrap_or("The TV denied this action") + .chars() + .take(240) + .collect()); + } + Ok(value) +} + +fn inventory_read(uri: &str, field: &str) -> Result { + // Only these fixed inventory commands use Homebrew's elevated service. + // Use the same one-reply invocation as the web launcher, without guessing + // CLI behavior from a firmware-version string. + let command = format!( + "if [ \"$(id -u)\" = 0 ]; then /usr/bin/luna-send -n 1 -w 4000 {uri} '{{}}'; else printf '%s\\n' '{{\"returnValue\":false,\"errorText\":\"Homebrew Channel needs root access\"}}'; fi" + ); + let envelope = call( + "luna://org.webosbrew.hbchannel.service/exec", + json!({"command": command}), + )?; + inventory_payload(&envelope, field).map_err(|error| { + let error = format!("{uri}: {error}"); + eprintln!("Luma TV inventory: {error}"); + error + }) +} + +pub fn read_app_icons(source: &app_artwork::Source) -> Result>, String> { + if !cfg!(feature = "webos") { + return Err("Installed artwork is only read on TV builds".into()); + } + let quote = |value: &str| format!("'{}'", value.replace('\'', "'\\''")); + let command = format!( + "/usr/bin/node -e {} {}", + quote(include_str!("../tv/read-app-icon.cjs")), + quote(&serde_json::to_string(source).map_err(|e| e.to_string())?) + ); + let envelope = call( + "luna://org.webosbrew.hbchannel.service/exec", + json!({"command": command}), + )?; + let icons = inventory_payload(&envelope, "icons")?; + let mut remaining = app_artwork::MAX_FILE; + icons + .as_array() + .ok_or("Invalid installed icon reply")? + .iter() + .map(|value| { + let hex = value.as_str().ok_or("Invalid installed icon data")?; + if hex.len() % 2 != 0 || hex.len() / 2 > remaining { + return Err("Installed icon reply exceeds its size limit".into()); + } + remaining -= hex.len() / 2; + hex.as_bytes() + .chunks_exact(2) + .map(|pair| { + let digit = |b: u8| { + (b as char) + .to_digit(16) + .ok_or("Invalid installed icon encoding") + }; + Ok((digit(pair[0])? * 16 + digit(pair[1])?) as u8) + }) + .collect() + }) + .collect() +} + +fn output_excerpt(output: &str) -> String { + output + .chars() + .map(|c| if c.is_whitespace() { ' ' } else { c }) + .filter(|c| !c.is_control()) + .take(320) + .collect() +} + +fn inventory_payload(envelope: &Value, field: &str) -> Result { + let stdout = envelope + .get("stdoutString") + .and_then(Value::as_str) + .unwrap_or(""); + let stderr = envelope + .get("stderrString") + .and_then(Value::as_str) + .unwrap_or(""); + let failure = |reason: &str| { + format!( + "{reason}; stdout: {}; stderr: {}", + output_excerpt(stdout), + output_excerpt(stderr), + ) + }; + for key in ["returnCode", "exitCode"] { + if let Some(code) = envelope.get(key) + && code != 0 + && code != "0" + { + return Err(failure(&format!( + "Homebrew inventory command failed ({key}={code})" + ))); + } + } + if stdout.trim().is_empty() { + return Err(failure("Homebrew returned no inventory")); + } + if stdout.len() > 2 * 1024 * 1024 { + return Err("Homebrew inventory exceeds the 2 MiB limit".into()); + } + // Parse an entire JSON object, including pretty-printed multi-line replies. + // A stream parser permits CLI timing text after that object; a preamble can + // precede its opening brace. Never mistake a nested one-line app for a reply. + let start = stdout + .find('{') + .ok_or_else(|| failure("Homebrew inventory contains no JSON object"))?; + let response = serde_json::Deserializer::from_str(&stdout[start..]) + .into_iter::() + .next() + .ok_or_else(|| failure("Homebrew inventory contains no JSON reply"))? + .map_err(|error| { + failure(&format!( + "Homebrew inventory JSON could not be parsed: {error}" + )) + })?; + if !response.is_object() || !response.get("returnValue").is_some_and(Value::is_boolean) { + return Err(failure("Homebrew inventory JSON is not a Luna response")); + } + let response = checked_response(response)?; + response + .get(field) + .cloned() + .ok_or_else(|| format!("Missing TV inventory field: {field}")) +} + +fn inventory() -> Result<(Inventory, Option), String> { + let apps = normalize_apps(&inventory_read( + "luna://com.webos.applicationManager/listApps", + "apps", + )?)?; + let inputs = inventory_read("luna://com.webos.service.eim/getAllInputStatus", "devices") + .and_then(|raw| normalize_inputs(&raw)); + match inputs { + Ok(inputs) => { + eprintln!( + "Luma TV inventory: {} apps, {} inputs", + apps.len(), + inputs.len() + ); + Ok((Inventory { apps, inputs }, None)) + } + Err(error) => Ok(( + Inventory { + apps, + inputs: vec![], + }, + Some(format!("Inputs could not be refreshed: {error}")), + )), + } +} + +fn title(value: Option<&str>, fallback: &str) -> String { + let clean: String = value + .unwrap_or("") + .chars() + .filter(|c| !c.is_control()) + .take(120) + .collect(); + if clean.trim().is_empty() { + fallback.into() + } else { + clean.trim().into() + } +} + +pub fn normalize_apps(raw: &Value) -> Result, String> { + let entries = raw + .as_array() + .filter(|a| a.len() <= 1000) + .ok_or("Invalid app inventory")?; + let mut seen = HashSet::new(); + let mut apps = Vec::new(); + for entry in entries { + let Some(id) = entry + .get("id") + .and_then(Value::as_str) + .filter(|id| valid_app_id(id)) + else { + continue; + }; + let live = id == "com.webos.app.livetv"; + let visible = entry.get("visible"); + if id == APP_ID + || id == "com.luma.native" + || id == "com.luma.launcher" + || id == "com.luma.launcher.native" + || id == "com.webos.app.home" + || id.starts_with("com.webos.app.hdmi") + || id.starts_with("com.webos.app.externalinput.") + || (!live && (visible == Some(&json!(false)) || visible == Some(&json!("false")))) + || (!live + && ["com.webos.", "com.palm.", "com.lge."] + .iter() + .any(|prefix| id.starts_with(prefix)) + && visible != Some(&json!(true))) + || !seen.insert(id) + { + continue; + } + let mut app = App::new(id, &title(entry.get("title").and_then(Value::as_str), id)); + app.icon = Some(app_artwork::Source::new(id, entry)); + apps.push(app); + } + apps.sort_by_key(|a| a.title.to_lowercase()); + Ok(apps) +} + +pub fn normalize_inputs(raw: &Value) -> Result, String> { + let entries = raw + .as_array() + .filter(|a| a.len() <= 128) + .ok_or("Invalid input inventory")?; + let mut inputs = Vec::new(); + for entry in entries { + let Some(id) = entry.get("appId").and_then(Value::as_str) else { + continue; + }; + let Some(number) = id + .strip_prefix("com.webos.app.hdmi") + .and_then(|s| s.parse::().ok()) + .filter(|n| *n > 0 && *n < 100) + else { + continue; + }; + if id != format!("com.webos.app.hdmi{number}") + || entries + .iter() + .filter(|e| e.get("appId").and_then(Value::as_str) == Some(id)) + .count() + != 1 + { + continue; + } + if entry + .get("id") + .is_some_and(|v| v != &format!("HDMI_{number}")) + { + continue; + } + if entry + .get("port") + .is_some_and(|v| v != &json!(number) && v != &number.to_string()) + { + continue; + } + let port = format!("HDMI {number}"); + inputs.push(Input { + id: id.into(), + title: title(entry.get("label").and_then(Value::as_str), &port), + port, + // Some firmware reports `connected: true` for every existing port; + // `hdmiPlugIn` is the actual cable state where it is provided. + connected: entry + .get("hdmiPlugIn") + .and_then(Value::as_bool) + .or_else(|| entry.get("connected").and_then(Value::as_bool)), + }); + } + Ok(inputs) +} diff --git a/src/raster.rs b/src/raster.rs new file mode 100644 index 0000000..6d6ad63 --- /dev/null +++ b/src/raster.rs @@ -0,0 +1,97 @@ +//! Logical layout is independent of drawable density. Vector coverage and +//! glyph-origin snapping use the drawable's physical pixel scale in shaders. +use serde::Serialize; +use std::str::FromStr; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct Size { + pub width: u32, + pub height: u32, +} +impl Size { + pub const HD: Self = Self { + width: 1920, + height: 1080, + }; + pub const UHD: Self = Self { + width: 3840, + height: 2160, + }; + pub const fn new(width: u32, height: u32) -> Self { + Self { width, height } + } + pub fn scale(self) -> f32 { + (self.width as f32 / 1920.).min(self.height as f32 / 1080.) + } + pub fn is_4k(self) -> bool { + self.width >= 3840 && self.height >= 2160 + } +} +impl FromStr for Size { + type Err = String; + fn from_str(value: &str) -> Result { + match value { + "1080p" => return Ok(Self::HD), + "4k" => return Ok(Self::UHD), + _ => {} + } + let (w, h) = value + .split_once('x') + .ok_or("Resolution must be 1080p, 4k, or WIDTHxHEIGHT")?; + let width = w.parse::().map_err(|e| e.to_string())?; + let height = h.parse::().map_err(|e| e.to_string())?; + if !(640..=7680).contains(&width) || !(360..=4320).contains(&height) { + return Err("Requested window must be between 640x360 and 7680x4320".into()); + } + Ok(Self { width, height }) + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +pub struct Viewport { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub scale: f32, +} +impl Viewport { + pub fn new(size: Size) -> Self { + let scale = size.scale(); + let width = (1920. * scale).round() as i32; + let height = (1080. * scale).round() as i32; + Self { + x: (size.width as i32 - width) / 2, + y: (size.height as i32 - height) / 2, + width, + height, + scale, + } + } + pub fn text_scale(self) -> f32 { + self.height as f32 / 1080. + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn a_letterboxed_drawable_uses_its_fitted_density() { + let viewport = Viewport::new(Size::new(3840, 2400)); + assert_eq!(viewport.scale, 2.); + assert_eq!(viewport.y, 120); + assert_eq!(viewport.width, 3840); + assert_eq!(viewport.height, 2160); + } + #[test] + fn requested_resolution_is_validated_separately_from_the_actual_drawable() { + assert_eq!("4k".parse::().unwrap(), Size::UHD); + assert_eq!("1280x720".parse::().unwrap(), Size::new(1280, 720)); + for invalid in ["0x0", "100000x2160", "3840", "-1x1080"] { + assert!(invalid.parse::().is_err()); + } + assert!(!Size::HD.is_4k()); + assert!(Size::UHD.is_4k()); + } +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..9f4fa18 --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,1370 @@ +//! A small GLES2-compatible GPU painter. Rust owns vector glyphs and the draw list; +//! SDL only supplies the context. No browser, LVGL or Qt runtime. +use crate::{ + font::Font, + raster::{Size, Viewport}, + state::Rect, + vector, +}; +use gl::types::*; +use serde::Serialize; +use std::{ + collections::{HashMap, HashSet}, + ffi::{CStr, CString}, + path::{Path, PathBuf}, + sync::Arc, + time::Instant, +}; + +pub type Color = [f32; 4]; +pub const WHITE: Color = [0.96, 0.96, 0.96, 1.]; +const GLYPH_GEOMETRY_BUDGET: usize = 8 * 1024 * 1024; +const TEXT_GEOMETRY_BUDGET: usize = 32 * 1024 * 1024; +const TEXTURE_BUDGET: usize = 48 * 1024 * 1024; + +#[derive(Clone, Copy, Debug)] +pub struct TextStyle { + pub size: f32, + pub weight: u16, + /// CSS letter-spacing in em, not pixels. + pub tracking: f32, + pub line_height: f32, + pub align_right: bool, +} +impl TextStyle { + pub const fn new(size: f32) -> Self { + Self { + size, + weight: 400, + tracking: 0., + line_height: 1.2, + align_right: false, + } + } + pub const fn weight(mut self, weight: u16) -> Self { + self.weight = weight; + self + } + pub const fn tracking(mut self, tracking: f32) -> Self { + self.tracking = tracking; + self + } + pub const fn leading(mut self, height: f32) -> Self { + self.line_height = height; + self + } + pub const fn right(mut self) -> Self { + self.align_right = true; + self + } +} + +#[derive(Clone)] +pub enum Draw { + Shape(Rect, f32, f32, Color), + RoundedGradient(Rect, f32, Color, Color), + Shadow(Rect, f32, f32, Color), + Text(String, Rect, TextStyle, Color, bool), + Image(String, Rect, f32, bool), + TintedImage(String, Rect, f32, bool, Color), + AppImage(Arc, Rect, Color), + Clip(Option), + EdgeFade(Option<[f32; 4]>), +} + +struct Texture { + id: GLuint, + width: u32, + height: u32, + frame: u64, +} +impl Drop for Texture { + fn drop(&mut self) { + unsafe { + gl::DeleteTextures(1, &self.id); + } + } +} + +struct Glyph { + geometry: Vec, + frame: u64, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +struct TextKey { + text: String, + weight: u16, + // Logical size, tracking, line height, available width and height. + layout: [u32; 5], + flags: u8, +} + +struct TextRun { + mesh: Option, + missing_glyphs: usize, + failures: usize, + frame: u64, +} + +#[derive(Clone, Serialize)] +pub struct GraphicsInfo { + pub vendor: String, + pub renderer: String, + pub version: String, + pub max_texture_size: u32, + pub max_viewport: [i32; 2], + pub fragment_highp: bool, +} + +#[derive(Clone, Serialize)] +pub struct RenderStats { + pub target: Size, + pub viewport: Viewport, + pub font_antialiasing: &'static str, + pub multisample_samples: u32, + pub max_logical_font_px: f32, + pub max_physical_font_px: f32, + pub cached_glyphs: usize, + pub glyph_tessellations: usize, + pub glyph_failures: usize, + pub missing_glyphs: usize, + pub missing_images: usize, + pub glyph_geometry_bytes: usize, + pub cached_text_runs: usize, + pub text_geometry_bytes: usize, + pub text_triangles: usize, + pub artwork_bytes: usize, + pub vector_meshes: usize, + pub vector_bytes: usize, + pub vector_triangles: usize, + pub high_resolution_wallpaper: bool, + pub draw_calls: usize, + pub cpu_submission_ms: f64, +} +impl RenderStats { + fn new(target: Size) -> Self { + Self { + target, + viewport: Viewport::new(target), + font_antialiasing: "vector grayscale coverage", + multisample_samples: 0, + max_logical_font_px: 0., + max_physical_font_px: 0., + cached_glyphs: 0, + glyph_tessellations: 0, + glyph_failures: 0, + missing_glyphs: 0, + missing_images: 0, + glyph_geometry_bytes: 0, + cached_text_runs: 0, + text_geometry_bytes: 0, + text_triangles: 0, + artwork_bytes: 0, + vector_meshes: 0, + vector_bytes: 0, + vector_triangles: 0, + high_resolution_wallpaper: false, + draw_calls: 0, + cpu_submission_ms: 0., + } + } +} + +enum BatchKind { + Paint { + texture: GLuint, + start: usize, + count: usize, + }, + Vector { + path: String, + rect: Rect, + tint: Color, + }, + Text { + key: TextKey, + origin: [f32; 2], + tint: Color, + }, +} + +struct Batch { + kind: BatchKind, + clip: Option, + fade: Option<[f32; 4]>, +} + +pub struct Renderer { + program: GLuint, + vector_program: GLuint, + text_program: GLuint, + buffer: GLuint, + vao: GLuint, + white: Texture, + font_directory: PathBuf, + fonts: HashMap, + failed_fonts: HashSet, + glyphs: HashMap<(char, u16), Glyph>, + text_runs: HashMap, + textures: HashMap>, + vectors: HashMap>, + artwork_variants: HashMap, + vertices: Vec, + batches: Vec, + clip: Option, + fade: Option<[f32; 4]>, + frame: u64, + stats: RenderStats, + pub info: GraphicsInfo, +} + +impl Renderer { + pub fn new(font: &Path, gles: bool) -> Result { + let directory = font.parent().ok_or("Invalid font path")?; + let mut fonts = HashMap::new(); + // Open only the common face. Metrics and outlines stay lazy even when + // the much larger CJK fallback is needed later. + fonts.insert(400, load_font(directory, 400)?); + let mut max_texture_size = 0; + let mut max_viewport = [0; 2]; + let mut precision = 0; + unsafe { + gl::GetIntegerv(gl::MAX_TEXTURE_SIZE, &mut max_texture_size); + gl::GetIntegerv(gl::MAX_VIEWPORT_DIMS, max_viewport.as_mut_ptr()); + if gles && gl::GetShaderPrecisionFormat::is_loaded() { + let mut range = [0; 2]; + gl::GetShaderPrecisionFormat( + gl::FRAGMENT_SHADER, + gl::HIGH_FLOAT, + range.as_mut_ptr(), + &mut precision, + ); + } + } + let fragment_highp = !gles || precision > 0; + let qualifier = if fragment_highp { "highp" } else { "mediump" }; + let vertex_prefix = if gles { + format!("#version 100\nprecision highp float;\n#define PAINT_PRECISION {qualifier}\n") + } else { + "#version 150\n#define attribute in\n#define varying out\n#define PAINT_PRECISION\n" + .into() + }; + let fragment_prefix = if gles { + format!( + "#version 100\nprecision {qualifier} float;\n#define PAINT_PRECISION {qualifier}\n" + ) + } else { + "#version 150\n#define varying in\n#define texture2D texture\nout vec4 out_color;\n#define gl_FragColor out_color\n#define PAINT_PRECISION\n".into() + }; + let program = link( + &(vertex_prefix.clone() + include_str!("shaders/paint.vert")), + &(fragment_prefix.clone() + include_str!("shaders/paint.frag")), + &[c"a_position", c"a_uv", c"a_color", c"a_shape", c"a_params"], + )?; + let vector_program = match link( + &(vertex_prefix.clone() + include_str!("shaders/vector.vert")), + &(fragment_prefix.clone() + include_str!("shaders/vector.frag")), + &[c"a_position", c"a_offset", c"a_color", c"a_coverage"], + ) { + Ok(program) => program, + Err(error) => { + unsafe { gl::DeleteProgram(program) }; + return Err(error); + } + }; + let text_program = match link( + &(vertex_prefix + include_str!("shaders/text.vert")), + &(fragment_prefix + include_str!("shaders/vector.frag")), + &[c"a_position", c"a_offset", c"a_origin", c"a_coverage"], + ) { + Ok(program) => program, + Err(error) => { + unsafe { + gl::DeleteProgram(program); + gl::DeleteProgram(vector_program); + } + return Err(error); + } + }; + let mut buffer = 0; + let mut vao = 0; + unsafe { + if !gles { + gl::GenVertexArrays(1, &mut vao); + gl::BindVertexArray(vao); + } + gl::GenBuffers(1, &mut buffer); + gl::Enable(gl::BLEND); + // Preserve an opaque destination alpha. Using BlendFunc for both + // channels makes translucent UI darken again in PNG/window compositing. + gl::BlendFuncSeparate( + gl::SRC_ALPHA, + gl::ONE_MINUS_SRC_ALPHA, + gl::ONE, + gl::ONE_MINUS_SRC_ALPHA, + ); + gl::Disable(gl::DEPTH_TEST); + gl::PixelStorei(gl::UNPACK_ALIGNMENT, 1); + } + let white = texture(1, 1, &[255; 4]); + Ok(Self { + program, + vector_program, + text_program, + buffer, + vao, + white, + font_directory: directory.into(), + fonts, + failed_fonts: HashSet::new(), + glyphs: HashMap::new(), + text_runs: HashMap::new(), + textures: HashMap::new(), + vectors: HashMap::new(), + artwork_variants: HashMap::new(), + vertices: Vec::with_capacity(65536), + batches: Vec::new(), + clip: None, + fade: None, + frame: 0, + stats: RenderStats::new(Size::HD), + info: GraphicsInfo { + vendor: gl_string(gl::VENDOR), + renderer: gl_string(gl::RENDERER), + version: gl_string(gl::VERSION), + max_texture_size: max_texture_size.max(0) as u32, + max_viewport, + fragment_highp, + }, + }) + } + + fn ensure_font(&mut self, id: u16) -> bool { + if self.fonts.contains_key(&id) { + return true; + } + if self.failed_fonts.contains(&id) { + return false; + } + match load_font(&self.font_directory, id) { + Ok(font) => { + self.fonts.insert(id, font); + true + } + Err(error) => { + eprintln!("{error}"); + self.failed_fonts.insert(id); + false + } + } + } + + fn glyph(&mut self, c: char, font: u16) -> Result<&[luma_vector_geometry::Vertex], String> { + let key = (c, font); + if !self.glyphs.contains_key(&key) { + let geometry = self.fonts[&font].geometry(c)?; + self.glyphs.insert( + key, + Glyph { + geometry, + frame: self.frame, + }, + ); + self.stats.glyph_tessellations += 1; + } + let glyph = self.glyphs.get_mut(&key).unwrap(); + glyph.frame = self.frame; + Ok(&glyph.geometry) + } + + fn trim_text_geometry(&mut self) { + let glyph_bytes = + |glyph: &Glyph| glyph.geometry.len() * size_of::(); + let mut bytes: usize = self.glyphs.values().map(glyph_bytes).sum(); + if bytes > GLYPH_GEOMETRY_BUDGET || self.glyphs.len() > 4096 { + let mut old: Vec<_> = self + .glyphs + .iter() + .filter(|(_, glyph)| glyph.frame != self.frame) + .map(|(key, glyph)| (*key, glyph.frame)) + .collect(); + old.sort_by_key(|(_, frame)| *frame); + for (key, _) in old { + if bytes <= GLYPH_GEOMETRY_BUDGET && self.glyphs.len() <= 4096 { + break; + } + if let Some(glyph) = self.glyphs.remove(&key) { + bytes -= glyph_bytes(&glyph); + } + } + } + let run_bytes = |run: &TextRun| run.mesh.as_ref().map_or(0, |mesh| mesh.bytes); + let mut bytes: usize = self.text_runs.values().map(run_bytes).sum(); + if bytes > TEXT_GEOMETRY_BUDGET || self.text_runs.len() > 512 { + let mut old: Vec<_> = self + .text_runs + .iter() + .filter(|(_, run)| run.frame != self.frame) + .map(|(key, run)| (key.clone(), run.frame)) + .collect(); + old.sort_by_key(|(_, frame)| *frame); + for (key, _) in old { + if bytes <= TEXT_GEOMETRY_BUDGET && self.text_runs.len() <= 512 { + break; + } + if let Some(run) = self.text_runs.remove(&key) { + bytes -= run_bytes(&run); + } + } + } + self.stats.cached_glyphs = self.glyphs.len(); + self.stats.glyph_geometry_bytes = self.glyphs.values().map(glyph_bytes).sum(); + self.stats.cached_text_runs = self.text_runs.len(); + self.stats.text_geometry_bytes = self.text_runs.values().map(run_bytes).sum(); + } + + fn quad( + &mut self, + rect: Rect, + uv: [f32; 4], + radius: f32, + border: f32, + color: Color, + texture: GLuint, + ) { + self.gradient_quad(rect, uv, radius, border, [color, color], texture); + } + + fn gradient_quad( + &mut self, + rect: Rect, + uv: [f32; 4], + radius: f32, + border: f32, + colors: [Color; 2], + texture: GLuint, + ) { + if rect.w <= 0. || rect.h <= 0. { + return; + } + let start = self.vertices.len() / 14; + for (x, y) in [(0., 0.), (1., 0.), (0., 1.), (0., 1.), (1., 0.), (1., 1.)] { + let color = colors[y as usize]; + self.vertices.extend_from_slice(&[ + rect.x + x * rect.w, + rect.y + y * rect.h, + uv[0] + x * (uv[2] - uv[0]), + uv[1] + y * (uv[3] - uv[1]), + color[0], + color[1], + color[2], + color[3], + (x - 0.5) * rect.w, + (y - 0.5) * rect.h, + rect.w / 2., + rect.h / 2., + radius, + border, + ]); + } + if let Some(batch) = self.batches.last_mut() { + let same_clip = match (batch.clip, self.clip) { + (None, None) => true, + (Some(a), Some(b)) => a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h, + _ => false, + }; + if same_clip + && batch.fade == self.fade + && let BatchKind::Paint { + texture: previous, + count, + .. + } = &mut batch.kind + && *previous == texture + { + *count += 6; + return; + } + } + self.batches.push(Batch { + kind: BatchKind::Paint { + texture, + start, + count: 6, + }, + clip: self.clip, + fade: self.fade, + }); + } + + fn text(&mut self, text: &str, rect: Rect, style: TextStyle, color: Color, centered: bool) { + // Layout remains logical. Reuse the same run geometry at every density; + // its shader snaps insertion origins and computes pixel-sized coverage. + let size_f = style.size; + let viewport = self.stats.viewport; + self.stats.max_logical_font_px = self.stats.max_logical_font_px.max(size_f); + self.stats.max_physical_font_px = self + .stats + .max_physical_font_px + .max(size_f * viewport.text_scale()); + let padded = rect.inset(-size_f); + if padded.x + padded.w < 0. + || padded.y + padded.h < 0. + || padded.x > 1920. + || padded.y > 1080. + || self.clip.is_some_and(|clip| { + padded.x + padded.w < clip.x + || padded.y + padded.h < clip.y + || padded.x > clip.x + clip.w + || padded.y > clip.y + clip.h + }) + { + return; + } + let origin = [rect.x, rect.y]; + let key = TextKey { + text: text.into(), + weight: style.weight, + layout: [ + size_f.to_bits(), + style.tracking.to_bits(), + style.line_height.to_bits(), + rect.w.to_bits(), + rect.h.to_bits(), + ], + flags: u8::from(style.align_right) + | (u8::from(centered) << 1) + | (u8::from(self.stats.multisample_samples > 1) << 2), + }; + if self.text_runs.contains_key(&key) { + self.submit_text(key, origin, color); + return; + } + let rect = Rect::new(0., 0., rect.w, rect.h); + let font_id = style.weight + if style.size >= 32. { 1000 } else { 0 }; + let cjk_id = if style.weight >= 500 { 2500 } else { 2400 }; + let hangul_id = cjk_id + 1000; + if !self.ensure_font(font_id) { + self.stats.glyph_failures += text.chars().count(); + return; + } + let (mut needs_cjk, mut needs_hangul) = (false, false); + for c in text.chars() { + if !c.is_ascii_control() && self.fonts[&font_id].lookup_glyph_index(c) == 0 { + if is_hangul(c) { + needs_hangul = true; + } else { + needs_cjk = true; + } + } + } + // Each fallback loads only when a run needs it, so Korean text never + // pulls in the larger Chinese font and vice versa. + let cjk_ready = needs_cjk && self.ensure_font(cjk_id); + let hangul_ready = needs_hangul && self.ensure_font(hangul_id); + let fonts = &self.fonts; + let choose_font = |c: char| { + if fonts[&font_id].lookup_glyph_index(c) != 0 { + font_id + } else if is_hangul(c) { + if hangul_ready { hangul_id } else { font_id } + } else if cjk_ready { + cjk_id + } else { + font_id + } + }; + let font = &fonts[&font_id]; + let line_height = size_f * style.line_height; + let baseline = font.horizontal_line_metrics(size_f).map_or(size_f, |m| { + (line_height - m.ascent + m.descent) / 2. + m.ascent + }); + let tracking = style.tracking * size_f; + let advance = |c| { + if c == '\n' { + 0. + } else { + fonts[&choose_font(c)].metrics(c, size_f).advance_width + tracking + } + }; + let mut lines: Vec = Vec::new(); + let mut line = String::new(); + let mut width = 0.; + // Word wrapping, with character fallback for a title wider than the tile. + for word in text.split_inclusive(char::is_whitespace) { + let word_width: f32 = word.chars().map(advance).sum(); + if width > 0. && width + word_width > rect.w { + lines.push(line.trim_end().into()); + line.clear(); + width = 0.; + } + for c in word.chars() { + if c == '\n' { + lines.push(line.trim_end().into()); + line.clear(); + width = 0.; + continue; + } + let w = advance(c); + if width > 0. && width + w > rect.w { + lines.push(line.trim_end().into()); + line.clear(); + width = 0.; + } + line.push(c); + width += w; + } + } + if !line.is_empty() { + lines.push(line.trim_end().into()); + } + let mut placed = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let top = rect.y + index as f32 * line_height; + if index > 0 && top + line_height > rect.y + rect.h + 1. { + break; + } + let mut previous = None; + let width: f32 = line + .chars() + .map(|c| { + let kern = previous + .and_then(|p| font.horizontal_kern(p, c, size_f)) + .unwrap_or(0.); + previous = Some(c); + advance(c) + kern + }) + .sum(); + let mut x = rect.x + + if style.align_right { + rect.w - width + } else if centered { + (rect.w - width) / 2. + } else { + 0. + }; + let mut previous = None; + for c in line.chars() { + if let Some(previous) = previous { + x += font.horizontal_kern(previous, c, size_f).unwrap_or(0.); + } + let selected = choose_font(c); + let baseline = if selected == font_id { + baseline + } else { + fonts[&selected] + .horizontal_line_metrics(size_f) + .map_or(baseline, |m| { + (line_height - m.ascent + m.descent) / 2. + m.ascent + }) + }; + placed.push((c, x, top + baseline, selected)); + x += advance(c); + previous = Some(c); + } + } + let mut vertices = Vec::new(); + let mut missing_glyphs = 0; + let mut failures = 0; + let multisampled = self.stats.multisample_samples > 1; + for (c, x, baseline, selected) in placed { + if self.fonts[&selected].lookup_glyph_index(c) == 0 && !c.is_whitespace() { + missing_glyphs += 1; + } + match self.glyph(c, selected) { + Ok(geometry) => { + for triangle in geometry.chunks_exact(3) { + // True sample coverage needs only the original fill. + // Exclude the boundary fringe, not just its alpha. + if multisampled && triangle.iter().any(|vertex| vertex.coverage < 1.) { + continue; + } + for vertex in triangle { + vertices.extend_from_slice(&[ + vertex.position.x * size_f, + vertex.position.y * size_f, + if multisampled { 0. } else { vertex.offset[0] }, + if multisampled { 0. } else { vertex.offset[1] }, + x, + baseline, + 0., + 0., + vertex.coverage, + ]); + } + } + } + Err(error) => { + eprintln!("Luma glyph {c:?}: {error}"); + failures += 1; + } + } + } + let mesh = if vertices.is_empty() { + None + } else { + match vector::Mesh::upload(1., 1., &vertices) { + Ok(mesh) => Some(mesh), + Err(error) => { + eprintln!("Luma text: {error}"); + failures += 1; + None + } + } + }; + self.text_runs.insert( + key.clone(), + TextRun { + mesh, + missing_glyphs, + failures, + frame: self.frame, + }, + ); + self.submit_text(key, origin, color); + } + + fn submit_text(&mut self, key: TextKey, origin: [f32; 2], tint: Color) { + let run = self.text_runs.get_mut(&key).unwrap(); + run.frame = self.frame; + self.stats.missing_glyphs += run.missing_glyphs; + self.stats.glyph_failures += run.failures; + if let Some(mesh) = &run.mesh { + self.stats.text_triangles += mesh.vertices as usize / 3; + self.batches.push(Batch { + kind: BatchKind::Text { key, origin, tint }, + clip: self.clip, + fade: self.fade, + }); + } + } + + fn image(&mut self, path: &str, mut rect: Rect, radius: f32, contain: bool, tint: Color) { + if path.ends_with(".lvec") { + self.vector_image(path, rect, contain, tint); + return; + } + let path = if self.stats.viewport.scale > 1. + && self.info.max_texture_size >= 3840 + && Path::new(path) + .parent() + .and_then(Path::file_name) + .is_some_and(|p| p == "wallpapers") + { + self.artwork_variants + .entry(path.into()) + .or_insert_with(|| { + let original = Path::new(path); + let variant = original.with_file_name(format!( + "{}@2x.jpg", + original.file_stem().unwrap_or_default().to_string_lossy() + )); + if variant.is_file() { + variant.to_string_lossy().into_owned() + } else { + path.into() + } + }) + .clone() + } else { + path.into() + }; + if path.ends_with("@2x.jpg") { + self.stats.high_resolution_wallpaper = true; + } + let frame = self.frame; + let entry = self + .textures + .entry(path.clone()) + .or_insert_with(|| match image::open(&path) { + Ok(image) => { + let image = image.into_rgba8(); + Some(texture(image.width(), image.height(), image.as_raw())) + } + Err(error) => { + eprintln!("Artwork {path}: {error}"); + None + } + }); + let Some(texture) = entry else { + self.stats.missing_images += 1; + return; + }; + texture.frame = frame; + let id = texture.id; + if contain { + let scale = (rect.w / texture.width as f32).min(rect.h / texture.height as f32); + let w = texture.width as f32 * scale; + let h = texture.height as f32 * scale; + rect = Rect::new(rect.x + (rect.w - w) / 2., rect.y + (rect.h - h) / 2., w, h); + } + self.quad(rect, [0., 0., 1., 1.], radius, 0., tint, id); + } + + fn vector_image(&mut self, path: &str, mut rect: Rect, contain: bool, tint: Color) { + if rect.w <= 0. || rect.h <= 0. { + return; + } + let mesh = self.vectors.entry(path.into()).or_insert_with(|| { + match vector::Mesh::load(Path::new(path)) { + Ok(mesh) => Some(mesh), + Err(error) => { + eprintln!("{error}"); + None + } + } + }); + let Some(mesh) = mesh else { + self.stats.missing_images += 1; + return; + }; + if contain { + let scale = (rect.w / mesh.width).min(rect.h / mesh.height); + let width = mesh.width * scale; + let height = mesh.height * scale; + rect = Rect::new( + rect.x + (rect.w - width) / 2., + rect.y + (rect.h - height) / 2., + width, + height, + ); + } + let clip = if let Some(clip) = self.clip { + let x = rect.x.max(clip.x); + let y = rect.y.max(clip.y); + Rect::new( + x, + y, + (rect.x + rect.w).min(clip.x + clip.w).max(x) - x, + (rect.y + rect.h).min(clip.y + clip.h).max(y) - y, + ) + } else { + rect + }; + if clip.w <= 0. || clip.h <= 0. { + return; + } + self.stats.vector_triangles += mesh.vertices as usize / 3; + self.batches.push(Batch { + kind: BatchKind::Vector { + path: path.into(), + rect, + tint, + }, + clip: Some(clip), + fade: self.fade, + }); + } + + fn app_image(&mut self, image: &crate::app_artwork::Artwork, rect: Rect, tint: Color) { + let entry = self.textures.entry(image.key.clone()).or_insert_with(|| { + Some(texture( + image.pixels.width(), + image.pixels.height(), + image.pixels.as_raw(), + )) + }); + let Some(texture) = entry else { + return; + }; + texture.frame = self.frame; + let scale = (rect.w / texture.width as f32).min(rect.h / texture.height as f32); + let w = texture.width as f32 * scale; + let h = texture.height as f32 * scale; + let id = texture.id; + self.quad( + Rect::new(rect.x + (rect.w - w) / 2., rect.y + (rect.h - h) / 2., w, h), + [0., 0., 1., 1.], + 0., + 0., + tint, + id, + ); + } + + pub fn render(&mut self, commands: &[Draw], width: u32, height: u32) { + let started = Instant::now(); + self.stats = RenderStats::new(Size::new(width, height)); + // Captures can bind a single-sample FBO even when the actual TV window + // is multisampled, so query the active target rather than assume. + let mut samples = 0; + unsafe { gl::GetIntegerv(gl::SAMPLES, &mut samples) }; + self.stats.multisample_samples = samples.max(0) as u32; + if samples > 1 { + self.stats.font_antialiasing = "multisample vector coverage"; + } + self.frame += 1; + self.vertices.clear(); + self.batches.clear(); + self.clip = None; + self.fade = None; + for command in commands { + match command { + Draw::Shape(rect, radius, border, color) => self.quad( + *rect, + [0., 0., 1., 1.], + *radius, + *border, + *color, + self.white.id, + ), + Draw::RoundedGradient(rect, radius, top, bottom) => self.gradient_quad( + *rect, + [0., 0., 1., 1.], + *radius, + 0., + [*top, *bottom], + self.white.id, + ), + Draw::Shadow(rect, radius, blur, color) => self.quad( + rect.inset(-*blur), + [0., 0., 1., 1.], + *radius, + -*blur, + *color, + self.white.id, + ), + Draw::Text(text, rect, size, color, centered) => { + self.text(text, *rect, *size, *color, *centered) + } + Draw::Image(path, rect, radius, contain) => { + self.image(path, *rect, *radius, *contain, [1.; 4]) + } + Draw::TintedImage(path, rect, radius, contain, tint) => { + self.image(path, *rect, *radius, *contain, *tint) + } + Draw::AppImage(image, rect, tint) => self.app_image(image, *rect, *tint), + Draw::Clip(clip) => self.clip = *clip, + Draw::EdgeFade(fade) => self.fade = *fade, + } + } + let Viewport { + x: vx, + y: vy, + width: vw, + height: vh, + scale, + } = self.stats.viewport; + unsafe { + gl::Disable(gl::SCISSOR_TEST); + gl::Viewport(0, 0, width as i32, height as i32); + gl::ClearColor(0.04, 0.04, 0.05, 1.); + gl::Clear(gl::COLOR_BUFFER_BIT); + gl::Viewport(vx, vy, vw, vh); + if self.vao != 0 { + gl::BindVertexArray(self.vao); + } + gl::BindBuffer(gl::ARRAY_BUFFER, self.buffer); + gl::BufferData( + gl::ARRAY_BUFFER, + (self.vertices.len() * size_of::()) as isize, + self.vertices.as_ptr().cast(), + gl::DYNAMIC_DRAW, + ); + gl::UseProgram(self.program); + gl::Uniform1f( + gl::GetUniformLocation(self.program, c"u_aa".as_ptr()), + 0.85 / scale.max(0.01), + ); + gl::Uniform1i( + gl::GetUniformLocation(self.program, c"u_texture".as_ptr()), + 0, + ); + gl::ActiveTexture(gl::TEXTURE0); + let mut paint_bound = false; + let mut current_fade = None; + for batch in &self.batches { + if current_fade != Some(batch.fade) { + // The same cheap alpha mask applies to shapes, textures, + // icons and text; the unmasked wallpaper remains beneath. + let fade = batch.fade.unwrap_or([0., 0., 0., 0.]); + for program in [self.program, self.vector_program, self.text_program] { + gl::UseProgram(program); + gl::Uniform4f( + gl::GetUniformLocation(program, c"u_edge_fade".as_ptr()), + fade[0], + fade[1], + fade[2], + fade[3], + ); + } + current_fade = Some(batch.fade); + paint_bound = false; + } + if let Some(clip) = batch.clip { + gl::Enable(gl::SCISSOR_TEST); + gl::Scissor( + vx + (clip.x * vw as f32 / 1920.).round() as i32, + vy + ((1080. - clip.y - clip.h) * vh as f32 / 1080.).round() as i32, + (clip.w * vw as f32 / 1920.).round() as i32, + (clip.h * vh as f32 / 1080.).round() as i32, + ); + } else { + gl::Disable(gl::SCISSOR_TEST); + } + match &batch.kind { + BatchKind::Paint { + texture, + start, + count, + } => { + if !paint_bound { + gl::UseProgram(self.program); + gl::BindBuffer(gl::ARRAY_BUFFER, self.buffer); + let mut offset = 0; + for (index, size) in [2, 2, 4, 4, 2].into_iter().enumerate() { + gl::EnableVertexAttribArray(index as u32); + gl::VertexAttribPointer( + index as u32, + size, + gl::FLOAT, + gl::FALSE, + 56, + (offset * size_of::()) as *const _, + ); + offset += size as usize; + } + paint_bound = true; + } + gl::BindTexture(gl::TEXTURE_2D, *texture); + gl::DrawArrays(gl::TRIANGLES, *start as i32, *count as i32); + } + BatchKind::Vector { path, rect, tint } => { + let Some(mesh) = self.vectors.get(path).and_then(Option::as_ref) else { + continue; + }; + self.draw_mesh( + self.vector_program, + mesh, + [rect.w / mesh.width, rect.h / mesh.height, rect.x, rect.y], + *tint, + ); + paint_bound = false; + } + BatchKind::Text { key, origin, tint } => { + let Some(mesh) = self.text_runs.get(key).and_then(|run| run.mesh.as_ref()) + else { + continue; + }; + self.draw_mesh( + self.text_program, + mesh, + [1., 1., origin[0], origin[1]], + *tint, + ); + paint_bound = false; + } + } + } + gl::Disable(gl::SCISSOR_TEST); + } + // Keep the current scene, evict old full-resolution wallpapers before memory grows. + let mut bytes: usize = self + .textures + .values() + .flatten() + .map(|t| t.width as usize * t.height as usize * 4) + .sum(); + if bytes > TEXTURE_BUDGET { + let mut old: Vec<_> = self + .textures + .iter() + .filter_map(|(path, t)| { + t.as_ref() + .filter(|t| t.frame != self.frame) + .map(|t| (path.clone(), t.frame)) + }) + .collect(); + old.sort_by_key(|(_, frame)| *frame); + for (path, _) in old { + if bytes <= TEXTURE_BUDGET { + break; + } + if let Some(Some(t)) = self.textures.remove(&path) { + bytes -= t.width as usize * t.height as usize * 4; + } + } + } + self.trim_text_geometry(); + self.stats.vector_meshes = self.vectors.values().flatten().count(); + self.stats.vector_bytes = self.vectors.values().flatten().map(|mesh| mesh.bytes).sum(); + self.stats.artwork_bytes = bytes + self.stats.vector_bytes; + self.stats.draw_calls = self.batches.len(); + self.stats.cpu_submission_ms = started.elapsed().as_secs_f64() * 1000.; + } + + fn draw_mesh(&self, program: GLuint, mesh: &vector::Mesh, transform: [f32; 4], tint: Color) { + let viewport = self.stats.viewport; + unsafe { + gl::UseProgram(program); + if program == self.text_program { + gl::Uniform1f( + gl::GetUniformLocation(program, c"u_fringe".as_ptr()), + if self.stats.multisample_samples > 1 { + 0. + } else { + 0.5 + }, + ); + } + gl::BindBuffer(gl::ARRAY_BUFFER, mesh.buffer); + gl::DisableVertexAttribArray(4); + let mut offset = 0; + for (index, size) in [2, 2, 4, 1].into_iter().enumerate() { + gl::EnableVertexAttribArray(index as u32); + gl::VertexAttribPointer( + index as u32, + size, + gl::FLOAT, + gl::FALSE, + 36, + (offset * size_of::()) as *const _, + ); + offset += size as usize; + } + gl::Uniform4f( + gl::GetUniformLocation(program, c"u_transform".as_ptr()), + transform[0], + transform[1], + transform[2], + transform[3], + ); + gl::Uniform2f( + gl::GetUniformLocation(program, c"u_pixel_scale".as_ptr()), + viewport.width as f32 / 1920., + viewport.height as f32 / 1080., + ); + gl::Uniform4f( + gl::GetUniformLocation(program, c"u_tint".as_ptr()), + tint[0], + tint[1], + tint[2], + tint[3], + ); + gl::DrawArrays(gl::TRIANGLES, 0, mesh.vertices); + } + } + + pub fn stats(&self) -> &RenderStats { + &self.stats + } + + pub fn screenshot(&self, path: &Path, width: u32, height: u32) -> Result<(), String> { + let mut pixels = vec![0; width as usize * height as usize * 4]; + unsafe { + gl::ReadPixels( + 0, + 0, + width as i32, + height as i32, + gl::RGBA, + gl::UNSIGNED_BYTE, + pixels.as_mut_ptr().cast(), + ); + } + let mut image = image::RgbaImage::from_raw(width, height, pixels) + .ok_or("Invalid framebuffer dimensions")?; + image::imageops::flip_vertical_in_place(&mut image); + image.save(path).map_err(|e| e.to_string()) + } + + /// Capture the authored TV surface, independent of desktop HiDPI/window limits. + pub fn capture( + &mut self, + commands: &[Draw], + path: &Path, + size: Size, + ) -> Result { + if size.width > self.info.max_texture_size + || size.height > self.info.max_texture_size + || size.width > self.info.max_viewport[0] as u32 + || size.height > self.info.max_viewport[1] as u32 + { + return Err(format!( + "Capture {}x{} exceeds GPU limits: {}px texture, {:?} viewport", + size.width, size.height, self.info.max_texture_size, self.info.max_viewport + )); + } + let image = texture(size.width, size.height, &[]); + let mut framebuffer = 0; + unsafe { + gl::GenFramebuffers(1, &mut framebuffer); + gl::BindFramebuffer(gl::FRAMEBUFFER, framebuffer); + gl::FramebufferTexture2D( + gl::FRAMEBUFFER, + gl::COLOR_ATTACHMENT0, + gl::TEXTURE_2D, + image.id, + 0, + ); + if gl::CheckFramebufferStatus(gl::FRAMEBUFFER) != gl::FRAMEBUFFER_COMPLETE { + gl::BindFramebuffer(gl::FRAMEBUFFER, 0); + gl::DeleteFramebuffers(1, &framebuffer); + return Err("Screenshot framebuffer is incomplete".into()); + } + } + self.render(commands, size.width, size.height); + let stats = self.stats.clone(); + let result = self.screenshot(path, size.width, size.height); + unsafe { + gl::BindFramebuffer(gl::FRAMEBUFFER, 0); + gl::DeleteFramebuffers(1, &framebuffer); + } + result.map(|_| stats) + } +} + +// Noto Sans SC (font-cjk) has no Hangul syllables; these go to font-kr instead. +// Keep in sync with is_hangul() in tools/font-weights.py, which builds font-kr. +fn is_hangul(c: char) -> bool { + matches!( + c, + '\u{1100}'..='\u{11FF}' + | '\u{3130}'..='\u{318F}' + | '\u{A960}'..='\u{A97F}' + | '\u{AC00}'..='\u{D7A3}' + | '\u{D7B0}'..='\u{D7FF}' + ) +} + +fn load_font(directory: &Path, id: u16) -> Result { + let name = if id >= 3000 { + format!("font-kr-{}.ttf", id - 3000) + } else if id >= 2000 { + format!("font-cjk-{}.ttf", id - 2000) + } else if id >= 1000 { + format!("font-display-{}.ttf", id - 1000) + } else { + format!("font-{id}.ttf") + }; + let path = directory.join(&name); + let started = Instant::now(); + let bytes = std::fs::read(&path) + .map_err(|e| format!("Font {}: {e}. Regenerate native assets.", path.display()))?; + let font = Font::from_bytes(bytes).map_err(|e| format!("Font {}: {e}", path.display()))?; + if cfg!(feature = "webos") { + eprintln!( + "Luma font: {name} loaded in {} ms", + started.elapsed().as_millis() + ); + } + Ok(font) +} + +impl Drop for Renderer { + fn drop(&mut self) { + unsafe { + gl::DeleteBuffers(1, &self.buffer); + gl::DeleteProgram(self.program); + gl::DeleteProgram(self.vector_program); + gl::DeleteProgram(self.text_program); + if self.vao != 0 { + gl::DeleteVertexArrays(1, &self.vao); + } + } + } +} + +fn texture(width: u32, height: u32, pixels: &[u8]) -> Texture { + let mut id = 0; + unsafe { + gl::GenTextures(1, &mut id); + gl::BindTexture(gl::TEXTURE_2D, id); + gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::LINEAR as i32); + gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::LINEAR as i32); + gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE as i32); + gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE as i32); + gl::TexImage2D( + gl::TEXTURE_2D, + 0, + gl::RGBA as i32, + width as i32, + height as i32, + 0, + gl::RGBA, + gl::UNSIGNED_BYTE, + if pixels.is_empty() { + std::ptr::null() + } else { + pixels.as_ptr().cast() + }, + ); + } + Texture { + id, + width, + height, + frame: 0, + } +} + +fn gl_string(name: GLenum) -> String { + unsafe { + let value = gl::GetString(name); + if value.is_null() { + "unknown".into() + } else { + CStr::from_ptr(value.cast()).to_string_lossy().into_owned() + } + } +} + +fn compile(kind: GLenum, source: &str) -> Result { + let source = CString::new(source).map_err(|e| e.to_string())?; + unsafe { + let shader = gl::CreateShader(kind); + gl::ShaderSource(shader, 1, &source.as_ptr(), std::ptr::null()); + gl::CompileShader(shader); + let mut success = 0; + gl::GetShaderiv(shader, gl::COMPILE_STATUS, &mut success); + if success == 0 { + let mut log = vec![0u8; 4096]; + let mut len = 0; + gl::GetShaderInfoLog(shader, log.len() as i32, &mut len, log.as_mut_ptr().cast()); + gl::DeleteShader(shader); + return Err(format!( + "Shader compilation: {}", + String::from_utf8_lossy(&log[..len as usize]) + )); + } + Ok(shader) + } +} + +fn link(vertex: &str, fragment: &str, attributes: &[&CStr]) -> Result { + let vertex = compile(gl::VERTEX_SHADER, vertex)?; + let fragment = match compile(gl::FRAGMENT_SHADER, fragment) { + Ok(fragment) => fragment, + Err(error) => { + unsafe { + gl::DeleteShader(vertex); + } + return Err(error); + } + }; + unsafe { + let program = gl::CreateProgram(); + gl::AttachShader(program, vertex); + gl::AttachShader(program, fragment); + for (index, name) in attributes.iter().enumerate() { + gl::BindAttribLocation(program, index as u32, name.as_ptr()); + } + gl::LinkProgram(program); + gl::DeleteShader(vertex); + gl::DeleteShader(fragment); + let mut success = 0; + gl::GetProgramiv(program, gl::LINK_STATUS, &mut success); + if success == 0 { + let mut log = vec![0u8; 4096]; + let mut len = 0; + gl::GetProgramInfoLog(program, log.len() as i32, &mut len, log.as_mut_ptr().cast()); + gl::DeleteProgram(program); + return Err(format!( + "Shader linking: {}", + String::from_utf8_lossy(&log[..len as usize]) + )); + } + Ok(program) + } +} diff --git a/src/scroll-preview.rs b/src/scroll-preview.rs new file mode 100644 index 0000000..730a842 --- /dev/null +++ b/src/scroll-preview.rs @@ -0,0 +1,40 @@ +//! Explicit manual scroll preview. Never used as a TV inventory fallback. +use crate::{i18n::Language, model::Inventory}; + +const PREFIX: &str = "com.luma.scroll-preview."; + +pub fn is_placeholder(id: &str) -> bool { + id.starts_with(PREFIX) +} + +pub fn append(inventory: &mut Inventory, count: usize, language: Language) { + if count == 0 { + return; + } + let templates = inventory.apps.clone(); + if templates.is_empty() { + eprintln!("Luma scroll preview: no real apps to use as artwork templates"); + return; + } + let mut added = 0; + for index in 0..count { + let id = format!("{PREFIX}{index:03}"); + if inventory.apps.iter().any(|app| app.id == id) { + continue; + } + let mut app = templates[index % templates.len()].clone(); + app.id = id; + app.title = format!( + "{} {:03} · {}", + language.t("scrollTestApp"), + index + 1, + app.title + ); + inventory.apps.push(app); + added += 1; + } + eprintln!( + "Luma scroll preview: {added} placeholder apps appended to {} real apps", + templates.len() + ); +} diff --git a/src/shaders/paint.frag b/src/shaders/paint.frag new file mode 100644 index 0000000..b023158 --- /dev/null +++ b/src/shaders/paint.frag @@ -0,0 +1,32 @@ +uniform sampler2D u_texture; +uniform float u_aa; +uniform vec4 u_edge_fade; +varying PAINT_PRECISION vec2 v_uv; +varying PAINT_PRECISION vec4 v_color; +varying PAINT_PRECISION vec4 v_shape; +varying PAINT_PRECISION vec2 v_params; +varying PAINT_PRECISION float v_logical_y; +float edge_coverage() { + if (u_edge_fade.y <= u_edge_fade.x) return 1.0; + return smoothstep(u_edge_fade.x, u_edge_fade.y, v_logical_y) * + (1.0 - smoothstep(u_edge_fade.z, u_edge_fade.w, v_logical_y)); +} +void main() { + if (v_params.x < 0.0) { + gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, edge_coverage()); + return; + } + float blur = max(-v_params.y, 0.0); + vec2 half_size = v_shape.zw - vec2(blur); + float radius = min(v_params.x, min(half_size.x, half_size.y)); + vec2 q = abs(v_shape.xy) - half_size + vec2(radius); + float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; + float alpha = 1.0 - smoothstep(-u_aa, 0.0, d); + if (blur > 0.0) { + alpha = 1.0 - smoothstep(-blur, blur, d); + } + if (v_params.y > 0.0) { + alpha *= smoothstep(-v_params.y - u_aa, -v_params.y, d); + } + gl_FragColor = texture2D(u_texture, v_uv) * v_color * vec4(1.0, 1.0, 1.0, alpha * edge_coverage()); +} diff --git a/src/shaders/paint.vert b/src/shaders/paint.vert new file mode 100644 index 0000000..d7a8201 --- /dev/null +++ b/src/shaders/paint.vert @@ -0,0 +1,18 @@ +attribute vec2 a_position; +attribute vec2 a_uv; +attribute vec4 a_color; +attribute vec4 a_shape; +attribute vec2 a_params; +varying PAINT_PRECISION vec2 v_uv; +varying PAINT_PRECISION vec4 v_color; +varying PAINT_PRECISION vec4 v_shape; +varying PAINT_PRECISION vec2 v_params; +varying PAINT_PRECISION float v_logical_y; +void main() { + gl_Position = vec4(a_position.x / 960.0 - 1.0, 1.0 - a_position.y / 540.0, 0.0, 1.0); + v_uv = a_uv; + v_color = a_color; + v_shape = a_shape; + v_params = a_params; + v_logical_y = a_position.y; +} diff --git a/src/shaders/text.vert b/src/shaders/text.vert new file mode 100644 index 0000000..b7aaf85 --- /dev/null +++ b/src/shaders/text.vert @@ -0,0 +1,24 @@ +attribute vec2 a_position; +attribute vec2 a_offset; +attribute vec4 a_origin; +attribute float a_coverage; +uniform vec4 u_transform; +uniform vec2 u_pixel_scale; +uniform vec4 u_tint; +uniform float u_fringe; +varying PAINT_PRECISION vec4 v_color; +varying PAINT_PRECISION float v_coverage; +varying PAINT_PRECISION float v_logical_y; +void main() { + // Snap each glyph's insertion origin/baseline, not individual outline + // vertices. Curves retain their shape while scrolling and changing density. + vec2 origin = a_origin.xy + u_transform.zw; + origin = floor(origin * u_pixel_scale + vec2(0.5)) / u_pixel_scale; + // With MSAA, use the original outline; inward offsets can fold thin stems. + // Single-sample targets retain a narrower analytic boundary fringe. + vec2 position = origin + a_position + a_offset * u_fringe / u_pixel_scale; + gl_Position = vec4(position.x / 960.0 - 1.0, 1.0 - position.y / 540.0, 0.0, 1.0); + v_color = u_tint; + v_coverage = mix(1.0, a_coverage, step(0.001, u_fringe)); + v_logical_y = position.y; +} diff --git a/src/shaders/vector.frag b/src/shaders/vector.frag new file mode 100644 index 0000000..3f2d662 --- /dev/null +++ b/src/shaders/vector.frag @@ -0,0 +1,12 @@ +varying PAINT_PRECISION vec4 v_color; +varying PAINT_PRECISION float v_coverage; +varying PAINT_PRECISION float v_logical_y; +uniform vec4 u_edge_fade; +void main() { + float edge = 1.0; + if (u_edge_fade.y > u_edge_fade.x) { + edge = smoothstep(u_edge_fade.x, u_edge_fade.y, v_logical_y) * + (1.0 - smoothstep(u_edge_fade.z, u_edge_fade.w, v_logical_y)); + } + gl_FragColor = vec4(v_color.rgb, v_color.a * clamp(v_coverage, 0.0, 1.0) * edge); +} diff --git a/src/shaders/vector.vert b/src/shaders/vector.vert new file mode 100644 index 0000000..a12febf --- /dev/null +++ b/src/shaders/vector.vert @@ -0,0 +1,22 @@ +attribute vec2 a_position; +attribute vec2 a_offset; +attribute vec4 a_color; +attribute float a_coverage; +uniform vec4 u_transform; +uniform vec2 u_pixel_scale; +uniform vec4 u_tint; +varying PAINT_PRECISION vec4 v_color; +varying PAINT_PRECISION float v_coverage; +varying PAINT_PRECISION float v_logical_y; +void main() { + vec2 position = a_position * u_transform.xy + u_transform.zw; + // Offsets are centered on the path boundary and stay half a physical pixel + // wide at every drawable density. Interior triangle edges have no fringe. + vec2 direction = a_offset / max(abs(u_transform.xy), vec2(0.000001)); + vec2 offset = direction / max(length(direction), 0.000001) * length(a_offset); + position += offset / u_pixel_scale; + gl_Position = vec4(position.x / 960.0 - 1.0, 1.0 - position.y / 540.0, 0.0, 1.0); + v_color = a_color * u_tint; + v_coverage = a_coverage; + v_logical_y = position.y; +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..3d5fd55 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,1561 @@ +use crate::i18n::Language; +use crate::model::{ + App, BACKGROUNDS, CATEGORIES, INPUT_NAME_LIMIT, InputMode, Inventory, Preferences, + clean_input_name, +}; +use crate::weather::{self, Unit}; +use serde::Serialize; +use std::time::{Duration, Instant}; + +pub const WEATHER_SECTION: usize = 2; +pub const INPUTS_SECTION: usize = 3; +pub const SYSTEM_SECTION: usize = 4; +pub const SECTION_COUNT: usize = 5; +pub const APPS_COLUMNS: usize = 6; +pub const HOME_STRIP_LEFT: f32 = 84.; +pub const HOME_STRIP_RIGHT: f32 = 1836.; +pub const HOME_TILE_WIDTH: f32 = 270.; +pub const HOME_TILE_PITCH: f32 = 296.4; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub enum Page { + Home, + Apps, + Ha, + Settings, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub enum Target { + Nav(usize), + TvSettings, + App(String), + Input(String), + Browse, + Filter(usize), + ClearFilter, + Search, + ClearSearch, + Sidebar(usize), + Setting(usize), + GreetingName, + GreetingNameClear, + AppNamesHome, + AppNamesApps, + ClockFormat, + ClockTimeFormat, + Background(usize), + InputMode(String), + InputRename, + InputNameReset, + Choice(usize), + CloseDialog, + Retry, + DismissNotice, + Option(usize), + Key(usize), + WeatherConfigure, + WeatherToggle, + WeatherLocation, + WeatherTemperature, + WeatherQuery, + WeatherSearch, + WeatherClear, + WeatherResult(usize), +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} +impl Rect { + pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self { + Self { x, y, w, h } + } + pub fn contains(self, x: f32, y: f32) -> bool { + x >= self.x && y >= self.y && x < self.x + self.w && y < self.y + self.h + } + pub fn center(self) -> (f32, f32) { + (self.x + self.w / 2., self.y + self.h / 2.) + } + pub fn inset(self, n: f32) -> Self { + Self::new(self.x + n, self.y + n, self.w - 2. * n, self.h - 2. * n) + } +} + +#[derive(Clone, Debug)] +pub struct Control { + pub target: Target, + pub rect: Rect, + pub scrollable: bool, +} + +#[derive(Clone, Debug)] +pub enum Overlay { + Options { id: String, restore: Target }, + Keyboard, + SystemKeyboard, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum Subpage { + AppNames, + Greeting, + Clock, + ClockFormat, + TimeFormat, + Input(String), + WeatherLocation, + Temperature, + Language, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub enum TextField { + Apps, + Location, + Name, + InputName, +} + +#[derive(Clone, Copy)] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +#[derive(Debug, PartialEq)] +pub enum Effect { + Save, + /// Read the app inventory again, keeping decoded icons of unchanged apps. + Refresh, + /// Refresh apps: read the inventory and every app icon again. + Reload, + Launch(String), + Quit, + Weather(weather::Command), +} + +pub const KEYS: [&str; 40] = [ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", + "T", "U", "V", "W", "X", "Y", "Z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "Space", + "Delete", "Clear", "Done", +]; + +pub struct State { + pub page: Page, + pub focus: Target, + pub inventory: Inventory, + pub preferences: Preferences, + pub query: String, + pub filter: usize, + pub section: usize, + pub subpage: Option, + pub scroll: f32, + pub home_scroll: f32, + pub overlay: Option, + pub moving: Option, + pub loading: bool, + /// Set by Refresh apps: the next inventory also reloads every app icon. + pub reload_artwork: bool, + pub launching: bool, + pub notice: Option<(String, Instant)>, + pub error: Option, + pub dirty: bool, + pub pointer: bool, + pub pressed: Option, + pub previous_background: Option<(String, Instant)>, + pub panel_started: Option, + pub weather: weather::Weather, + pub keyboard_field: TextField, + pub greeting_name_draft: String, + pub input_name: String, +} + +impl State { + pub fn new(preferences: Preferences) -> Self { + Self { + page: Page::Home, + focus: Target::Nav(0), + inventory: Inventory::default(), + preferences, + query: String::new(), + filter: 0, + section: 0, + subpage: None, + scroll: 0., + home_scroll: 0., + overlay: None, + moving: None, + loading: false, + reload_artwork: false, + launching: false, + notice: None, + error: None, + dirty: true, + pointer: false, + pressed: None, + previous_background: None, + panel_started: None, + weather: weather::Weather::default(), + keyboard_field: TextField::Apps, + greeting_name_draft: String::new(), + input_name: String::new(), + } + } + + pub fn notify(&mut self, text: impl Into) { + self.notice = Some((text.into(), Instant::now())); + self.dirty = true; + } + + pub fn tick(&mut self) { + if let Some(start) = self.panel_started { + self.dirty = true; + let duration = if self.overlay.is_some() { 120 } else { 150 }; + if self.preferences.reduced_motion || start.elapsed() >= Duration::from_millis(duration) + { + self.panel_started = None; + } + } + if self + .notice + .as_ref() + .is_some_and(|(_, start)| start.elapsed() < Duration::from_millis(120)) + && !self.preferences.reduced_motion + { + self.dirty = true; + } + if let Some((_, start)) = &self.previous_background { + self.dirty = true; + if self.preferences.reduced_motion || start.elapsed() >= Duration::from_millis(400) { + self.previous_background = None; + } + } + if self + .notice + .as_ref() + .is_some_and(|(_, start)| start.elapsed() > Duration::from_secs(5)) + { + self.notice = None; + self.dirty = true; + } + } + + pub fn page_index(&self) -> usize { + match self.page { + Page::Home => 0, + Page::Apps => 1, + Page::Ha => 2, + Page::Settings => 3, + } + } + + pub fn animating(&self) -> bool { + self.previous_background.is_some() + || self.panel_started.is_some() + || (!self.preferences.reduced_motion + && self + .notice + .as_ref() + .is_some_and(|(_, start)| start.elapsed() < Duration::from_millis(120))) + } + + pub fn open(&mut self, index: usize) { + self.weather.cancel_search(); + self.page = match index { + 1 => Page::Apps, + 2 => Page::Ha, + 3 => Page::Settings, + _ => Page::Home, + }; + self.focus = Target::Nav(self.page_index()); + self.scroll = 0.; + if self.page == Page::Home { + self.scroll_favorites(0.); + } + self.query.clear(); + self.filter = 0; + self.overlay = None; + self.moving = None; + self.subpage = None; + self.panel_started = None; + self.dirty = true; + } + + pub fn apps(&self) -> Vec<&App> { + let ids = self.preferences.favorites(&self.inventory.apps); + if self.page == Page::Home { + return ids + .iter() + .filter(|id| !self.preferences.hidden.contains(id)) + .filter_map(|id| self.inventory.apps.iter().find(|app| &app.id == id)) + .collect(); + } + let query = self.query.to_lowercase(); + let mut apps: Vec<_> = self + .inventory + .apps + .iter() + .filter(|app| { + self.preferences.hidden.contains(&app.id) == (self.filter == 5) + && (self.filter == 0 + || self.filter == 5 + || app.category == CATEGORIES[self.filter - 1]) + && app.title.to_lowercase().contains(&query) + }) + .collect(); + apps.sort_by(|a, b| { + let rank = |app: &App| { + ids.iter() + .position(|id| id == &app.id) + .unwrap_or(usize::MAX) + }; + rank(a) + .cmp(&rank(b)) + .then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) + }); + apps + } + + pub fn scroll_favorites(&mut self, delta: f32) { + let count = self + .preferences + .favorites(&self.inventory.apps) + .iter() + .filter(|id| { + !self.preferences.hidden.contains(id) + && self.inventory.apps.iter().any(|app| &app.id == *id) + }) + .count(); + let content = if count == 0 { + 0. + } else { + (count - 1) as f32 * HOME_TILE_PITCH + HOME_TILE_WIDTH + }; + let max = (content - (HOME_STRIP_RIGHT - HOME_STRIP_LEFT)).max(0.); + let next = (self.home_scroll + delta).clamp(0., max); + if next != self.home_scroll { + self.home_scroll = next; + self.dirty = true; + } + } + + pub fn set_inventory(&mut self, next: Inventory, warning: Option) { + self.inventory.apps = next.apps; + if let Some(warning) = warning { + self.notify(warning); + } else { + self.inventory.inputs = next.inputs; + } + self.loading = false; + self.error = None; + self.dirty = true; + self.reconcile_focus(); + } + + pub fn reconcile_focus(&mut self) { + if self.page == Page::Home { + self.scroll_favorites(0.); + } + if let Target::App(id) = &self.focus + && !self.apps().iter().any(|app| &app.id == id) + { + self.focus = self.first_content(); + } + if let Target::Input(id) = &self.focus + && !self + .inventory + .inputs + .iter() + .any(|input| &input.id == id && self.preferences.input_visible(input)) + { + self.focus = self.first_content(); + } + } + + pub fn first_content(&self) -> Target { + match self.page { + Page::Home => self + .apps() + .get(((self.home_scroll - 0.01).max(0.) / HOME_TILE_PITCH).ceil() as usize) + .map(|app| Target::App(app.id.clone())) + .unwrap_or(if self.error.is_some() { + Target::Retry + } else { + Target::Browse + }), + Page::Apps => self + .apps() + .first() + .map(|app| Target::App(app.id.clone())) + .unwrap_or(Target::Filter(1)), + Page::Ha => Target::Nav(2), + Page::Settings => Target::Sidebar(0), + } + } + + pub fn focus(&mut self, target: Target, controls: &[Control]) { + self.pointer = false; + self.focus = target; + self.reveal_focus(controls); + if let Target::Sidebar(section) = self.focus { + if self.subpage == Some(Subpage::WeatherLocation) { + self.weather.cancel_search(); + } + self.section = section; + self.subpage = None; + } + self.dirty = true; + } + + /// Remote focus is revealed after layout changes, including hide/reorder. + /// Pointer paths deliberately do not call this method. + pub fn reveal_focus(&mut self, controls: &[Control]) -> bool { + let previous = self.scroll; + let previous_home = self.home_scroll; + if let Some(control) = controls.iter().find(|c| c.target == self.focus) { + if self.page == Page::Home && matches!(self.focus, Target::App(_)) { + if control.rect.x < HOME_STRIP_LEFT { + self.home_scroll += control.rect.x - HOME_STRIP_LEFT; + } else if control.rect.x + control.rect.w > HOME_STRIP_RIGHT { + self.home_scroll += control.rect.x + control.rect.w - HOME_STRIP_RIGHT; + } + } + if matches!( + self.focus, + Target::Nav(_) + | Target::Filter(_) + | Target::ClearFilter + | Target::Search + | Target::ClearSearch + | Target::Sidebar(_) + ) { + self.scroll = 0.; + } else if control.scrollable { + let top = if self.page == Page::Apps { + 164.8 + } else { + 120.8 + }; + if control.rect.y < top { + self.scroll = (self.scroll + control.rect.y - top).max(0.); + } + let bottom = if self.page == Page::Home && self.moving.is_none() { + 1041.6 + } else { + 982.4 + }; + if control.rect.y + control.rect.h > bottom { + self.scroll += control.rect.y + control.rect.h - bottom; + } + } + } + if self.page == Page::Home { + self.scroll_favorites(0.); + } + if self.scroll != previous || self.home_scroll != previous_home { + self.dirty = true; + } + self.scroll != previous || self.home_scroll != previous_home + } + + /// Hover and pointer-down must not move the control before pointer-up. + pub fn pointer_focus(&mut self, target: Target) { + if !self.pointer { + self.dirty = true; + } + self.pointer = true; + if self.focus == target { + return; + } + if let Target::Sidebar(section) = target { + if self.subpage == Some(Subpage::WeatherLocation) { + self.weather.cancel_search(); + } + self.section = section; + self.subpage = None; + } + self.focus = target; + self.dirty = true; + } + + pub fn hidden_count(&self) -> usize { + self.inventory + .apps + .iter() + .filter(|app| self.preferences.hidden.contains(&app.id)) + .count() + } + + fn section_entry(&self) -> Target { + match self.section { + 1 => Target::Background( + BACKGROUNDS + .iter() + .position(|(id, _)| *id == self.preferences.background) + .unwrap_or(3), + ), + WEATHER_SECTION => Target::WeatherToggle, + SYSTEM_SECTION => Target::Setting(3), + INPUTS_SECTION => self + .inventory + .inputs + .first() + .map(|input| Target::InputMode(input.id.clone())) + .unwrap_or(Target::Setting(3)), + _ => Target::Setting(0), + } + } + + fn close_subpage(&mut self) { + if self.subpage == Some(Subpage::WeatherLocation) { + self.weather.cancel_search(); + } + self.panel_started = None; + self.pointer = false; + self.focus = match self.subpage.take() { + Some(Subpage::AppNames) => Target::Setting(0), + Some(Subpage::Greeting) => Target::Setting(6), + Some(Subpage::Clock) => Target::Setting(1), + Some(Subpage::ClockFormat) => { + self.subpage = Some(Subpage::Clock); + Target::ClockFormat + } + Some(Subpage::TimeFormat) => { + self.subpage = Some(Subpage::Clock); + Target::ClockTimeFormat + } + Some(Subpage::Input(id)) => Target::InputMode(id), + Some(Subpage::WeatherLocation) => Target::WeatherLocation, + Some(Subpage::Temperature) => Target::WeatherTemperature, + Some(Subpage::Language) => Target::Setting(4), + None => Target::Sidebar(self.section), + }; + self.scroll = 0.; + self.dirty = true; + } + + pub fn direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { + self.pointer = false; + self.dirty = true; + if matches!(self.overlay, Some(Overlay::SystemKeyboard)) { + // The TV keyboard owns its arrows until Done/Back dismisses it. + return vec![]; + } + if let Some(id) = self.moving.clone() { + let mut ids = self.preferences.favorites(&self.inventory.apps); + let slots: Vec<_> = ids + .iter() + .enumerate() + .filter_map(|(index, id)| { + (!self.preferences.hidden.contains(id) + && self.inventory.apps.iter().any(|app| &app.id == id)) + .then_some(index) + }) + .collect(); + if let Some(index) = slots.iter().position(|slot| ids[*slot] == id) { + let next = match direction { + Direction::Left => index.saturating_sub(1), + Direction::Right => (index + 1).min(slots.len() - 1), + _ => index, + }; + if next != index { + ids.swap(slots[index], slots[next]); + self.preferences.favorites = Some(ids); + self.dirty = true; + return vec![Effect::Save]; + } + } + return vec![]; + } + if self.overlay.is_none() { + if self.page == Page::Home + && let Target::App(id) = &self.focus + { + let apps = self.apps(); + if let Some(index) = apps.iter().position(|app| &app.id == id) { + match direction { + Direction::Left | Direction::Right => { + let next = if matches!(direction, Direction::Left) { + index.saturating_sub(1) + } else { + (index + 1).min(apps.len() - 1) + }; + let id = apps[next].id.clone(); + self.focus(Target::App(id), controls); + } + Direction::Up => self.focus(Target::Nav(0), controls), + Direction::Down => { + let x = controls + .iter() + .find(|control| control.target == self.focus) + .map_or(960., |control| control.rect.center().0); + let top = controls + .iter() + .filter(|control| matches!(control.target, Target::Input(_))) + .map(|control| control.rect.y) + .fold(f32::INFINITY, f32::min); + let next = controls + .iter() + .filter(|control| { + matches!(control.target, Target::Input(_)) + && (control.rect.y - top).abs() < 1. + }) + .min_by(|a, b| { + (a.rect.center().0 - x) + .abs() + .total_cmp(&(b.rect.center().0 - x).abs()) + }) + .map(|control| control.target.clone()); + if let Some(next) = next { + self.focus(next, controls); + } + } + } + return vec![]; + } + } + let setting_rows = match self.subpage { + Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]), + Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]), + Some(Subpage::Clock) => Some([Target::ClockFormat, Target::ClockTimeFormat]), + _ => None, + }; + if let Some(rows) = setting_rows + && let Some(index) = rows.iter().position(|target| *target == self.focus) + { + match direction { + Direction::Left => self.close_subpage(), + Direction::Up => self.focus( + if index == 0 { + Target::Nav(3) + } else { + rows[0].clone() + }, + controls, + ), + Direction::Down if index == 0 => self.focus(rows[1].clone(), controls), + Direction::Right + if matches!( + self.focus, + Target::GreetingName | Target::ClockFormat | Target::ClockTimeFormat + ) => + { + return self.activate(); + } + _ => {} + } + return vec![]; + } + if let Target::Choice(index) = self.focus { + let count = match self.subpage { + Some(Subpage::ClockFormat | Subpage::TimeFormat | Subpage::Temperature) => 2, + Some(Subpage::Language) => Language::ALL.len(), + _ => 3, + }; + match direction { + Direction::Left => self.close_subpage(), + Direction::Up => self.focus( + if index == 0 { + Target::Nav(3) + } else { + Target::Choice(index - 1) + }, + controls, + ), + Direction::Down + if index + 1 == count + && matches!(self.subpage, Some(Subpage::Input(_))) => + { + self.focus(Target::InputRename, controls) + } + Direction::Down => self.focus(Target::Choice((index + 1) % count), controls), + Direction::Right => {} + } + return vec![]; + } + if matches!(self.focus, Target::InputRename | Target::InputNameReset) { + match direction { + Direction::Left => self.close_subpage(), + Direction::Up => self.focus( + if self.focus == Target::InputNameReset { + Target::InputRename + } else { + Target::Choice(2) + }, + controls, + ), + Direction::Down + if self.focus == Target::InputRename + && controls.iter().any(|c| c.target == Target::InputNameReset) => + { + self.focus(Target::InputNameReset, controls) + } + _ => {} + } + return vec![]; + } + if let Target::Sidebar(index) = self.focus { + match direction { + Direction::Right => { + let entry = self.section_entry(); + self.focus(entry, controls); + } + Direction::Up => self.focus( + if index == 0 { + Target::Nav(3) + } else { + Target::Sidebar(index - 1) + }, + controls, + ), + Direction::Down => { + self.focus(Target::Sidebar((index + 1) % SECTION_COUNT), controls) + } + Direction::Left => {} + } + return vec![]; + } + if matches!( + self.focus, + Target::Setting(0 | 1 | 4 | 6) + | Target::InputMode(_) + | Target::WeatherLocation + | Target::WeatherTemperature + ) && matches!(direction, Direction::Right) + { + return self.activate(); + } + if matches!(self.focus, Target::Setting(_) | Target::WeatherToggle) + && matches!(direction, Direction::Right) + { + return vec![]; + } + if let Target::InputMode(id) = &self.focus + && matches!(direction, Direction::Up) + && self + .inventory + .inputs + .first() + .is_some_and(|input| &input.id == id) + { + self.focus(Target::Sidebar(INPUTS_SECTION), controls); + return vec![]; + } + if self.focus == Target::Setting(0) && matches!(direction, Direction::Up) { + self.focus(Target::Nav(3), controls); + return vec![]; + } + if self.focus == Target::WeatherToggle && matches!(direction, Direction::Up) { + self.focus(Target::Nav(3), controls); + return vec![]; + } + if matches!( + self.focus, + Target::WeatherQuery | Target::WeatherSearch | Target::WeatherClear + ) { + match direction { + Direction::Left => { + self.close_subpage(); + return vec![]; + } + Direction::Up => { + self.focus( + if self.preferences.weather_location.is_some() + && self.focus != Target::WeatherClear + { + Target::WeatherClear + } else { + Target::Nav(3) + }, + controls, + ); + return vec![]; + } + Direction::Down => { + let target = if self.focus == Target::WeatherClear { + Some(Target::WeatherQuery) + } else if !self.weather.results.is_empty() { + Some(Target::WeatherResult(0)) + } else if self.focus == Target::WeatherQuery { + Some(Target::WeatherSearch) + } else { + None + }; + if let Some(target) = target { + self.focus(target, controls); + } + return vec![]; + } + _ => {} + } + } + if let Target::WeatherResult(index) = self.focus { + match direction { + Direction::Left => self.close_subpage(), + Direction::Up => self.focus( + if index == 0 { + Target::WeatherQuery + } else { + Target::WeatherResult(index - 1) + }, + controls, + ), + Direction::Down if index + 1 < self.weather.results.len() => { + self.focus(Target::WeatherResult(index + 1), controls) + } + _ => {} + } + return vec![]; + } + if self.focus == Target::Search { + match direction { + Direction::Left => { + self.focus( + Target::Filter(if self.hidden_count() > 0 { 5 } else { 4 }), + controls, + ); + return vec![]; + } + Direction::Up => { + self.focus(Target::Nav(1), controls); + return vec![]; + } + _ => {} + } + } + if let Target::Nav(index) = self.focus { + let next = match direction { + Direction::Left if index > 0 => Some(index - 1), + Direction::Right if index < 3 => Some(index + 1), + _ => None, + }; + if let Some(next) = next { + self.open(next); + return vec![]; + } + } + if self.page == Page::Apps + && let Target::App(id) = &self.focus + && matches!(direction, Direction::Up | Direction::Down) + { + let apps = self.apps(); + if let Some(index) = apps.iter().position(|app| &app.id == id) { + // A preceding row may be above the visible scroll region. + // Its grid index still defines the route; reveal_focus then + // scrolls it into view, without letting the fixed nav steal Up. + let next = match direction { + Direction::Up if index < APPS_COLUMNS => Some(Target::Filter(1)), + Direction::Up => Some(Target::App(apps[index - APPS_COLUMNS].id.clone())), + Direction::Down + if (index / APPS_COLUMNS + 1) * APPS_COLUMNS < apps.len() => + { + Some(Target::App( + apps[(index + APPS_COLUMNS).min(apps.len() - 1)].id.clone(), + )) + } + _ => None, + }; + if let Some(next) = next { + self.focus(next, controls); + } + return vec![]; + } + } + if let Target::Background(index) = self.focus { + match direction { + Direction::Right if index + 1 < BACKGROUNDS.len() => { + self.focus(Target::Background(index + 1), controls); + return vec![]; + } + Direction::Left if index % 3 != 0 => { + self.focus(Target::Background(index - 1), controls); + return vec![]; + } + _ => {} + } + } + if matches!(self.focus, Target::Nav(_)) && matches!(direction, Direction::Down) { + if self.page == Page::Settings { + self.subpage = None; + self.section = 0; + } + self.focus(self.first_content(), controls); + return vec![]; + } + if self.page == Page::Settings + && matches!(direction, Direction::Left) + && !matches!( + self.focus, + Target::Nav(_) | Target::Sidebar(_) | Target::TvSettings + ) + { + if let Target::Background(index) = self.focus { + if index % 3 != 0 { /* left follows gallery order */ + } else { + self.focus(Target::Sidebar(self.section), controls); + return vec![]; + } + } else { + self.focus(Target::Sidebar(self.section), controls); + return vec![]; + } + } + } + let Some(current) = controls.iter().find(|c| c.target == self.focus) else { + self.focus(self.first_content(), controls); + return vec![]; + }; + let (x, y) = current.rect.center(); + let next = controls + .iter() + .filter(|candidate| candidate.target != self.focus) + .filter(|candidate| { + self.page != Page::Home + || !matches!(candidate.target, Target::App(_)) + || (candidate.rect.x >= HOME_STRIP_LEFT - 0.1 + && candidate.rect.x + candidate.rect.w <= HOME_STRIP_RIGHT + 0.1) + }) + .filter_map(|candidate| { + // Header controls never steal an Up route from the active tab. + if self.overlay.is_none() + && matches!(direction, Direction::Up) + && candidate.rect.y < 150. + && candidate.target != Target::Nav(self.page_index()) + { + return None; + } + let (cx, cy) = candidate.rect.center(); + let (primary, cross, overlap) = match direction { + Direction::Up => ( + y - cy, + (x - cx).abs(), + (current.rect.w + candidate.rect.w) / 2., + ), + Direction::Down => ( + cy - y, + (x - cx).abs(), + (current.rect.w + candidate.rect.w) / 2., + ), + Direction::Left => ( + x - cx, + (y - cy).abs(), + (current.rect.h + candidate.rect.h) / 2., + ), + Direction::Right => ( + cx - x, + (y - cy).abs(), + (current.rect.h + candidate.rect.h) / 2., + ), + }; + (primary > 1.).then_some(( + candidate.target.clone(), + primary + cross * 2. + if cross >= overlap { 10000. } else { 0. }, + )) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(target, _)| target); + if let Some(next) = next { + self.focus(next, controls); + } + vec![] + } + + pub fn options(&mut self) { + if self.overlay.is_some() || self.moving.is_some() { + return; + } + if let Target::App(id) = &self.focus { + self.overlay = Some(Overlay::Options { + id: id.clone(), + restore: self.focus.clone(), + }); + self.focus = Target::Option(0); + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + self.dirty = true; + } + } + + pub fn hold(&mut self) { + if self.page == Page::Home && self.overlay.is_none() { + if let Target::App(id) = &self.focus { + self.moving = Some(id.clone()); + self.notice = None; + self.dirty = true; + } + } else { + self.options(); + } + } + + pub fn text(&mut self, text: &str) { + if self.text_input_active() { + if self.keyboard_field == TextField::Location { + let mut query = self.weather.query.clone(); + query.extend( + text.chars() + .filter(|c| !c.is_control()) + .take(120usize.saturating_sub(query.chars().count())), + ); + self.weather.edit(query); + self.dirty = true; + return; + } + if self.keyboard_field == TextField::Name { + let name = &mut self.greeting_name_draft; + name.extend( + text.chars() + .filter(|c| !c.is_control()) + .take(40usize.saturating_sub(name.chars().count())), + ); + self.dirty = true; + return; + } + if self.keyboard_field == TextField::InputName { + let length = self.input_name.chars().count(); + self.input_name.extend( + text.chars() + .filter(|c| !c.is_control()) + .take(INPUT_NAME_LIMIT.saturating_sub(length)), + ); + self.dirty = true; + return; + } + self.query.extend( + text.chars() + .filter(|c| !c.is_control()) + .take(120usize.saturating_sub(self.query.chars().count())), + ); + self.scroll = 0.; + self.dirty = true; + } + } + + pub fn delete(&mut self) { + if self.text_input_active() { + if self.keyboard_field == TextField::Location { + let mut query = self.weather.query.clone(); + query.pop(); + self.weather.edit(query); + } else if self.keyboard_field == TextField::Name { + self.greeting_name_draft.pop(); + } else if self.keyboard_field == TextField::InputName { + self.input_name.pop(); + } else { + self.query.pop(); + } + self.dirty = true; + } + } + + pub fn text_input_active(&self) -> bool { + matches!( + self.overlay, + Some(Overlay::Keyboard | Overlay::SystemKeyboard) + ) + } + + pub fn finish_text_input(&mut self) -> Vec { + if self.text_input_active() { + self.overlay = None; + self.panel_started = None; + self.focus = self.keyboard_return(); + self.pointer = false; + self.dirty = true; + return self.commit_text_input(); + } + vec![] + } + + fn commit_text_input(&mut self) -> Vec { + if self.keyboard_field == TextField::Name { + let name = std::mem::take(&mut self.greeting_name_draft); + let name = name.trim(); + // Empty dismissal preserves the saved value; Clear name is explicit. + if !name.is_empty() && self.preferences.greeting_name != name { + self.preferences.greeting_name = name.into(); + return vec![Effect::Save]; + } + return vec![]; + } + self.commit_input_name() + } + + /// Closing the keyboard by any route keeps a typed input name. An empty + /// field leaves the saved name unchanged; Clear name removes it explicitly. + fn commit_input_name(&mut self) -> Vec { + let name = std::mem::take(&mut self.input_name); + if self.keyboard_field != TextField::InputName || clean_input_name(&name).is_empty() { + return vec![]; + } + let Some(Subpage::Input(id)) = self.subpage.clone() else { + return vec![]; + }; + if self.preferences.set_input_name(&id, &name) { + vec![Effect::Save] + } else { + vec![] + } + } + + pub fn delete_surrounding_text(&mut self, offset: i32, length: i32) { + if !self.text_input_active() || length <= 0 { + return; + } + // SDL-webOS forwards the native text model's UTF-8 byte offsets, + // relative to the insertion cursor (at the end of our query). + let mut query = self.editing_text().to_owned(); + let mut start = + (query.len() as i64 + i64::from(offset)).clamp(0, query.len() as i64) as usize; + let mut end = start.saturating_add(length as usize).min(query.len()); + while !query.is_char_boundary(start) { + start -= 1; + } + while !query.is_char_boundary(end) { + end += 1; + } + query.replace_range(start..end, ""); + if self.keyboard_field == TextField::Location { + self.weather.edit(query); + } else if self.keyboard_field == TextField::Name { + self.greeting_name_draft = query; + } else if self.keyboard_field == TextField::InputName { + self.input_name = query; + } else { + self.query = query; + self.scroll = 0.; + } + self.dirty = true; + } + + pub fn editing_text(&self) -> &str { + match self.keyboard_field { + TextField::Location => &self.weather.query, + TextField::Name => &self.greeting_name_draft, + TextField::InputName => &self.input_name, + TextField::Apps => &self.query, + } + } + pub fn open_keyboard(&mut self, field: TextField) { + self.keyboard_field = field; + if field == TextField::Name { + // SDL-webOS cannot pre-fill the system keyboard's text model. + self.greeting_name_draft.clear(); + } + if field == TextField::InputName { + // The TV keyboard starts empty, so a new name replaces the old one. + self.input_name.clear(); + } + if cfg!(all( + feature = "webos", + target_os = "linux", + target_arch = "arm" + )) { + self.overlay = Some(Overlay::SystemKeyboard); + self.focus = match field { + TextField::Location => Target::WeatherQuery, + TextField::Name => Target::GreetingName, + TextField::InputName => Target::InputRename, + TextField::Apps => Target::Search, + }; + self.panel_started = None; + self.dirty = true; + return; + } + self.overlay = Some(Overlay::Keyboard); + self.focus = Target::Key(0); + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + self.dirty = true; + } + fn open_location(&mut self) { + self.weather.edit(String::new()); + self.subpage = Some(Subpage::WeatherLocation); + self.focus = Target::WeatherQuery; + self.scroll = 0.; + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + self.dirty = true; + } + fn keyboard_return(&self) -> Target { + match self.keyboard_field { + TextField::Location => Target::WeatherSearch, + TextField::Name => Target::GreetingName, + TextField::InputName => Target::InputRename, + TextField::Apps => Target::Search, + } + } + + pub fn activate(&mut self) -> Vec { + if self.moving.take().is_some() { + // Order was saved at every move. A fresh OK release finishes the + // mode and keeps the selected favorite, rather than launching it. + self.dirty = true; + return vec![]; + } + self.dirty = true; + match self.focus.clone() { + Target::Nav(index) => self.open(index), + Target::Browse => self.open(1), + Target::Retry => return vec![Effect::Reload], + Target::DismissNotice => self.notice = None, + Target::CloseDialog => return self.back(), + Target::TvSettings => return vec![Effect::Launch("com.palm.app.settings".into())], + Target::App(id) | Target::Input(id) if !self.launching => { + return vec![Effect::Launch(id)]; + } + Target::Filter(index) => { + self.filter = if self.filter == index { 0 } else { index }; + self.scroll = 0.; + } + Target::ClearFilter => { + self.filter = 0; + self.focus = Target::Filter(1); + self.scroll = 0.; + } + Target::ClearSearch => { + self.query.clear(); + self.focus = Target::Search; + self.scroll = 0.; + } + Target::Search => { + self.open_keyboard(TextField::Apps); + } + Target::WeatherConfigure => { + self.open(3); + self.section = WEATHER_SECTION; + self.open_location(); + self.open_keyboard(TextField::Location); + } + Target::WeatherToggle => { + self.preferences.show_weather = !self.preferences.show_weather; + self.weather.invalidate_forecast(); + return vec![Effect::Save]; + } + Target::WeatherLocation => self.open_location(), + Target::WeatherTemperature => { + self.subpage = Some(Subpage::Temperature); + self.focus = Target::Choice(usize::from( + self.preferences.temperature_unit == Unit::Fahrenheit, + )); + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + } + Target::WeatherQuery => self.open_keyboard(TextField::Location), + Target::GreetingName => self.open_keyboard(TextField::Name), + Target::GreetingNameClear => { + self.preferences.greeting_name.clear(); + self.focus = Target::GreetingName; + return vec![Effect::Save]; + } + Target::AppNamesHome => { + self.preferences.show_home_names = !self.preferences.show_home_names; + return vec![Effect::Save]; + } + Target::AppNamesApps => { + self.preferences.show_app_names = !self.preferences.show_app_names; + return vec![Effect::Save]; + } + Target::ClockFormat | Target::ClockTimeFormat => { + let format = self.focus == Target::ClockFormat; + self.subpage = Some(if format { + Subpage::ClockFormat + } else { + Subpage::TimeFormat + }); + self.focus = Target::Choice(usize::from(if format { + self.preferences.hour_24 + } else { + self.preferences.show_seconds + })); + self.scroll = 0.; + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + } + Target::InputRename => self.open_keyboard(TextField::InputName), + Target::InputNameReset => { + if let Some(Subpage::Input(id)) = &self.subpage + && self.preferences.input_names.remove(id).is_some() + { + self.focus = Target::InputRename; + return vec![Effect::Save]; + } + } + Target::WeatherSearch => { + return self + .weather + .search(self.preferences.language) + .map(|command| vec![Effect::Weather(command)]) + .unwrap_or_default(); + } + Target::WeatherClear => { + self.preferences.weather_location = None; + self.weather.remove_location(); + self.focus = Target::WeatherSearch; + return vec![Effect::Save, Effect::Weather(weather::Command::ClearCache)]; + } + Target::WeatherResult(index) => { + if let Some(place) = self.weather.results.get(index).cloned() { + self.preferences.weather_location = Some(place); + self.weather.invalidate_forecast(); + self.weather.cancel_search(); + self.focus = Target::WeatherSearch; + return vec![Effect::Save]; + } + } + Target::Sidebar(index) => { + self.section = index; + self.scroll = 0.; + self.subpage = None; + self.focus = self.section_entry(); + } + Target::Setting(index) => { + match index { + 0 => { + self.subpage = Some(Subpage::AppNames); + self.focus = Target::AppNamesHome; + self.scroll = 0.; + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + return vec![]; + } + 1 => { + self.subpage = Some(Subpage::Clock); + self.scroll = 0.; + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + self.focus = Target::ClockFormat; + return vec![]; + } + 2 => self.preferences.reduced_motion = !self.preferences.reduced_motion, + 4 => { + self.subpage = Some(Subpage::Language); + self.focus = Target::Choice(self.preferences.language.index()); + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + return vec![]; + } + 5 => return vec![Effect::Quit], + 6 => { + self.subpage = Some(Subpage::Greeting); + self.focus = Target::GreetingName; + self.scroll = 0.; + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + return vec![]; + } + _ => return vec![Effect::Reload], + } + return vec![Effect::Save]; + } + Target::Background(index) => { + if !self.preferences.reduced_motion + && self.preferences.background != BACKGROUNDS[index].0 + { + self.previous_background = + Some((self.preferences.background.clone(), Instant::now())); + } + self.preferences.background = BACKGROUNDS[index].0.into(); + return vec![Effect::Save]; + } + Target::InputMode(id) => { + let mode = self + .preferences + .input_modes + .get(&id) + .copied() + .unwrap_or_default(); + self.subpage = Some(Subpage::Input(id)); + if !self.preferences.reduced_motion { + self.panel_started = Some(Instant::now()); + } + self.focus = Target::Choice(match mode { + InputMode::On => 0, + InputMode::Off => 1, + InputMode::Auto => 2, + }); + } + Target::Choice(index) => { + match self.subpage.clone() { + Some(Subpage::ClockFormat) => self.preferences.hour_24 = index == 1, + Some(Subpage::TimeFormat) => self.preferences.show_seconds = index == 1, + Some(Subpage::Input(id)) => { + self.preferences.input_modes.insert( + id, + [InputMode::On, InputMode::Off, InputMode::Auto][index.min(2)], + ); + } + Some(Subpage::Temperature) => { + self.preferences.temperature_unit = if index == 1 { + Unit::Fahrenheit + } else { + Unit::Celsius + } + } + Some(Subpage::Language) => { + self.preferences.language = + Language::ALL[index.min(Language::ALL.len() - 1)]; + self.weather.cancel_search(); + } + Some( + Subpage::WeatherLocation + | Subpage::Greeting + | Subpage::AppNames + | Subpage::Clock, + ) => { + return vec![]; + } + None => return vec![], + } + self.close_subpage(); + return vec![Effect::Save]; + } + Target::Option(index) => { + let Some(Overlay::Options { id, restore }) = self.overlay.take() else { + return vec![]; + }; + self.panel_started = None; + let old_ids: Vec<_> = self.apps().iter().map(|a| a.id.clone()).collect(); + let old_index = old_ids.iter().position(|item| item == &id).unwrap_or(0); + let mut favorites = self.preferences.favorites(&self.inventory.apps); + let title = self + .inventory + .apps + .iter() + .find(|app| app.id == id) + .map_or(id.clone(), |app| app.title.clone()); + if index == 0 { + if favorites.contains(&id) { + favorites.retain(|item| item != &id); + self.notify( + self.preferences + .language + .format("favoriteRemoved", &[("app", &title)]), + ); + } else { + favorites.push(id.clone()); + self.notify( + self.preferences + .language + .format("favoriteAdded", &[("app", &title)]), + ); + } + self.preferences.hidden.retain(|item| item != &id); + self.preferences.favorites = Some(favorites); + } else if index == 1 { + if self.preferences.hidden.contains(&id) { + self.preferences.hidden.retain(|item| item != &id); + self.notify( + self.preferences + .language + .format("appRestored", &[("app", &title)]), + ); + } else { + self.preferences.hidden.push(id.clone()); + self.notify( + self.preferences + .language + .format("appHidden", &[("app", &title)]), + ); + } + } + if self.filter == 5 && self.hidden_count() == 0 { + self.filter = 0; + } + self.focus = restore; + self.pointer = false; + if let Target::App(id) = &self.focus { + let apps = self.apps(); + if !apps.iter().any(|app| &app.id == id) { + self.focus = apps + .get(old_index.min(apps.len().saturating_sub(1))) + .map(|app| Target::App(app.id.clone())) + .unwrap_or_else(|| self.first_content()); + } + } + return if index < 2 { + vec![Effect::Save] + } else { + vec![] + }; + } + Target::Key(index) => match KEYS[index] { + "Space" => self.text(" "), + "Delete" => self.delete(), + "Clear" => match self.keyboard_field { + TextField::Location => self.weather.edit(String::new()), + TextField::Name => self.greeting_name_draft.clear(), + TextField::InputName => self.input_name.clear(), + TextField::Apps => self.query.clear(), + }, + "Done" => { + self.overlay = None; + self.panel_started = None; + self.focus = self.keyboard_return(); + return self.commit_text_input(); + } + key => self.text(key), + }, + _ => {} + } + vec![] + } + + pub fn back(&mut self) -> Vec { + self.pointer = false; + self.dirty = true; + if self.moving.take().is_some() { + return vec![]; + } + if let Some(overlay) = self.overlay.take() { + self.panel_started = None; + if let Overlay::Options { restore, .. } = overlay { + self.focus = restore; + return vec![]; + } + self.focus = self.keyboard_return(); + return self.commit_text_input(); + } + if self.page == Page::Settings && self.subpage.is_some() { + self.close_subpage(); + return vec![]; + } + if self.page == Page::Settings && !matches!(self.focus, Target::Nav(_)) { + if matches!(self.focus, Target::Sidebar(_)) { + self.focus = Target::Nav(3); + } else { + self.focus = Target::Sidebar(self.section); + } + self.scroll = 0.; + return vec![]; + } + if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) { + self.query.clear(); + self.focus = Target::Nav(1); + self.scroll = 0.; + return vec![]; + } + if self.page != Page::Home { + self.open(0); + vec![] + } else { + vec![Effect::Quit] + } + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..42ff958 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,176 @@ +use crate::model::Preferences; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub fn default_path(webos: bool) -> Result { + if webos { + #[cfg(all(feature = "webos", unix))] + return tv_path(); + #[cfg(not(all(feature = "webos", unix)))] + return Err("TV storage requires a webOS build".into()); + } + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".config"))) + .ok_or("Set HOME, XDG_CONFIG_HOME or --preferences for native preferences")?; + Ok(base.join("luma-native/preferences.json")) +} + +#[cfg(all(feature = "webos", unix))] +fn tv_path() -> Result { + use std::{ + io::Write, + os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}, + }; + + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let legacy = executable.with_file_name("preferences.json"); + let id = crate::platform::APP_ID; + // These volumes persist across boots. The app-specific directory also + // survives replacement of the installed payload on an ordinary update. + // Production native jails expose internal storage instead of developer. + let developer = PathBuf::from("/media/developer") + .join(id) + .join("preferences.json"); + let shared_developer = PathBuf::from("/media/developer/temp") + .join(id) + .join("preferences.json"); + let internal = PathBuf::from("/media/internal") + .join(format!(".{id}")) + .join("preferences.json"); + let mut candidates = if executable.starts_with("/media/cryptofs") { + [internal, developer, shared_developer] + } else { + [developer, shared_developer, internal] + }; + // Keep a previously used destination even if another candidate becomes + // writable after a reboot; do not accidentally restart with fresh settings. + candidates.sort_by_key(|path| !path.exists()); + let mut failures = Vec::new(); + for path in candidates { + let directory = path.parent().ok_or("Invalid TV storage directory")?; + // Create only this app's leaf, not a missing system mount hierarchy. + let created = fs::DirBuilder::new().mode(0o700).create(directory); + if let Err(error) = created + && error.kind() != std::io::ErrorKind::AlreadyExists + { + failures.push(format!("{}: {error}", directory.display())); + continue; + } + let metadata = match fs::symlink_metadata(directory) { + Ok(metadata) => metadata, + Err(error) => { + failures.push(format!("{}: {error}", directory.display())); + continue; + } + }; + // Do not silently reuse a symlink or root-created debug directory as + // another process's private app storage. + if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } { + failures.push(format!( + "{} is not owned by the app user", + directory.display() + )); + continue; + } + let probe = directory.join(format!(".write-probe-{}", std::process::id())); + let writable = (|| { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&probe)?; + let result = file + .write_all(b"Luma storage probe") + .and_then(|_| file.sync_all()); + drop(file); + // Only remove a probe that this process created successfully. + let cleanup = fs::remove_file(&probe); + result.and(cleanup) + })(); + if let Err(error) = writable { + failures.push(format!("{}: {error}", directory.display())); + continue; + } + if !path.exists() { + // Import JSON only from the old path in THIS installation, never + // browser storage or another app ID's preferences. + match fs::read(&legacy) { + Ok(bytes) => { + let mut preferences: Preferences = + serde_json::from_slice(&bytes).map_err(|e| { + format!( + "Existing preferences {} could not be read: {e}", + legacy.display() + ) + })?; + preferences.sanitize(); + save(&path, &preferences)?; + eprintln!( + "Luma preferences migrated from {} to {}", + legacy.display(), + path.display() + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Existing preferences {} could not be read: {error}", + legacy.display() + )); + } + } + let old_cache = crate::weather_service::cache_path(&legacy); + let cache = crate::weather_service::cache_path(&path); + if !cache.exists() + && let Some(reading) = crate::weather_service::load_cache(&old_cache) + && let Err(error) = crate::weather_service::save_cache(&cache, &reading) + { + eprintln!("Luma weather cache migration: {error}"); + } + } + eprintln!("Luma persistent storage: {}", directory.display()); + return Ok(path); + } + Err(format!( + "No writable persistent TV storage found: {}", + failures.join("; ") + )) +} + +pub fn load(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => { + let mut preferences: Preferences = serde_json::from_slice(&bytes) + .map_err(|e| format!("Preferences could not be read: {e}"))?; + preferences.sanitize(); + Ok(preferences) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Preferences::default()), + Err(e) => Err(format!("Preferences could not be read: {e}")), + } +} + +pub fn save(path: &Path, preferences: &Preferences) -> Result<(), String> { + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent).map_err(|e| { + format!( + "Could not prepare preferences directory {}: {e}", + parent.display() + ) + })?; + } + let temporary = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(preferences) + .map_err(|e| format!("Could not encode preferences: {e}"))?; + fs::write(&temporary, bytes).map_err(|e| { + format!( + "Could not write preferences temporary file {}: {e}", + temporary.display() + ) + })?; + fs::rename(&temporary, path) + .map_err(|e| format!("Could not replace preferences file {}: {e}", path.display())) +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..a8c0b75 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,1238 @@ +use crate::{ + model::{App, Input, InputMode, Preferences, demo_inventory}, + platform::{normalize_apps, normalize_inputs}, + state::{Direction, Effect, Overlay, Page, State, Target}, + storage, view, +}; +use serde_json::json; +use std::{collections::BTreeMap, path::PathBuf}; + +fn ready() -> State { + let mut state = State::new(Preferences::default()); + state.set_inventory(demo_inventory(), None); + state +} + +fn scene(state: &State) -> view::Scene { + let assets = view::Assets { + root: PathBuf::new(), + version: "test".into(), + metrics: BTreeMap::new(), + artwork: crate::app_artwork::Service::new(false), + }; + let navigation = view::navigation(&assets, state.preferences.language); + view::scene_with_pill( + state, + &assets, + navigation.items[state.page_index()].x, + navigation.items[state.page_index()].w, + time::OffsetDateTime::UNIX_EPOCH, + ) +} + +fn controls(state: &State) -> Vec { + scene(state).controls +} + +#[test] +fn remote_routes_from_header_and_back_through_settings() { + let mut state = ready(); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::App("netflix".into())); + state.direction(Direction::Up, &controls(&state)); + assert_eq!(state.focus, Target::Nav(0)); + state.open(3); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Sidebar(0)); + state.activate(); + assert_eq!(state.focus, Target::Setting(0)); + assert!(state.back().is_empty()); + assert_eq!(state.focus, Target::Sidebar(0)); + state.back(); + assert_eq!(state.focus, Target::Nav(3)); + state.back(); + assert_eq!(state.page, Page::Home); + assert_eq!(state.back(), vec![Effect::Quit]); +} + +#[test] +fn hiding_restores_nearest_app_and_hidden_filter_can_restore_it() { + let mut state = ready(); + state.open(1); + state.focus = Target::App("netflix".into()); + state.options(); + state.focus = Target::Option(1); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(state.preferences.hidden.contains(&"netflix".into())); + assert_eq!(state.focus, Target::App("youtube.leanback.v4".into())); + state.filter = 5; + assert_eq!(state.apps().len(), 1); + state.focus = Target::App("netflix".into()); + state.options(); + state.focus = Target::Option(1); + state.activate(); + assert!(state.preferences.hidden.is_empty()); + assert_eq!(state.focus, Target::App("netflix".into())); + assert_eq!(state.filter, 0); +} + +#[test] +fn reorder_persists_order_and_keeps_selected_identity() { + let mut state = ready(); + state.focus = Target::App("netflix".into()); + state.hold(); + assert_eq!( + state.direction(Direction::Right, &controls(&state)), + vec![Effect::Save] + ); + assert_eq!( + &state.preferences.favorites(&state.inventory.apps)[..2], + &["youtube.leanback.v4", "netflix"] + ); + assert_eq!(state.focus, Target::App("netflix".into())); + assert!(state.back().is_empty()); + assert!(state.moving.is_none()); +} + +#[test] +fn keyboard_is_modal_and_back_preserves_then_clears_search() { + let mut state = ready(); + state.open(1); + state.focus = Target::Search; + state.activate(); + assert!( + controls(&state) + .iter() + .all(|c| matches!(c.target, Target::Key(_) | Target::CloseDialog)) + ); + state.text("netflix"); + state.delete(); + state.text("x"); + state.back(); + assert_eq!(state.query, "netflix"); + assert_eq!(state.focus, Target::Search); + assert_eq!(state.apps().len(), 1); + state.back(); + assert!(state.query.is_empty()); + assert_eq!(state.focus, Target::Nav(1)); + assert_eq!(state.page, Page::Apps); +} + +#[test] +fn partial_refresh_preserves_valid_inputs_and_never_invents_tv_apps() { + let mut state = ready(); + state.set_inventory( + crate::model::Inventory::default(), + Some("Inputs unavailable".into()), + ); + assert!(state.inventory.apps.is_empty()); + assert_eq!(state.inventory.inputs.len(), 3); + assert_eq!(state.first_content(), Target::Browse); +} + +#[test] +fn inventory_filters_system_apps_duplicates_and_unsafe_identifiers() { + let apps = normalize_apps(&json!([ + {"id":"netflix","title":" Netflix\n"}, {"id":"netflix","title":"Duplicate"}, + {"id":"bad;command","title":"Bad"}, {"id":"com.webos.app.hidden","title":"Internal"}, + {"id":"com.webos.app.livetv","visible":false,"title":"Live TV"}, + {"id":"com.luma.launcher.native","visible":true}, + {"id":"com.webos.app.hdmi1","visible":true}, + {"id":"hidden","visible":"false"} + ])) + .unwrap(); + assert_eq!(apps.len(), 2); + assert_eq!( + apps.iter().find(|a| a.id == "netflix").unwrap().title, + "Netflix" + ); + assert!(normalize_apps(&json!({"apps":[]})).is_err()); + let inputs = normalize_inputs(&json!([ + {"appId":"com.webos.app.hdmi1","id":"HDMI_1","port":1,"connected":true}, + {"appId":"com.webos.app.hdmi2","id":"HDMI_4"}, + {"appId":"com.webos.app.hdmi3"}, {"appId":"com.webos.app.hdmi3"}, + {"appId":"com.webos.app.hdmi4","label":"Console"} + ])) + .unwrap(); + assert_eq!(inputs.len(), 2); + assert_eq!(inputs[1].connected, None); +} + +#[test] +fn inventory_visibility_cases_match_the_launcher_filters() { + let cases: Vec = + serde_json::from_str(include_str!("../tests/fixtures/app-visibility.json")).unwrap(); + let inventory: Vec<_> = cases.iter().map(|case| case["app"].clone()).collect(); + let actual: std::collections::BTreeSet<_> = normalize_apps(&json!(inventory)) + .unwrap() + .into_iter() + .map(|app| app.id) + .collect(); + let expected: std::collections::BTreeSet<_> = cases + .iter() + .filter(|case| case["include"] == true) + .map(|case| case["app"]["id"].as_str().unwrap().to_owned()) + .collect(); + assert_eq!(actual, expected); +} + +#[test] +fn asset_loading_and_unknown_app_cards_do_not_require_collected_artwork() { + let root = std::env::temp_dir().join(format!("luma-assets-no-dumps-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("version.txt"), "test").unwrap(); + std::fs::write(root.join("font-metrics.json"), "{}").unwrap(); + for webos in [false, true] { + let assets = view::Assets::load(root.clone(), webos).unwrap(); + let mut state = State::new(Preferences::default()); + state.set_inventory( + crate::model::Inventory { + apps: vec![App::new("com.example.regional", "Regional App")], + inputs: vec![], + }, + None, + ); + state.open(1); + let scene = view::scene_with_pill( + &state, + &assets, + 800., + 100., + time::OffsetDateTime::UNIX_EPOCH, + ); + assert!(scene.draw.iter().any(|draw| matches!(draw, + crate::renderer::Draw::TintedImage(path, ..) if path.ends_with("ui-icons/generic.lvec")))); + assert!(scene.draw.iter().any(|draw| matches!(draw, + crate::renderer::Draw::Text(value, ..) if value == "Regional App"))); + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn auto_input_visibility_requires_a_positive_connection_report() { + let mut preferences = Preferences::default(); + let mut input = Input { + id: "com.webos.app.hdmi1".into(), + title: "Console".into(), + port: "HDMI 1".into(), + connected: None, + }; + assert!(!preferences.input_visible(&input)); + input.connected = Some(true); + assert!(preferences.input_visible(&input)); + preferences + .input_modes + .insert(input.id.clone(), InputMode::Off); + assert!(!preferences.input_visible(&input)); + preferences + .input_modes + .insert(input.id.clone(), InputMode::On); + input.connected = Some(false); + assert!(preferences.input_visible(&input)); +} + +#[test] +fn preferences_round_trip_and_corrupt_data_is_not_silently_overwritten() { + let directory = std::env::temp_dir().join(format!("luma-native-test-{}", std::process::id())); + let path = directory.join("preferences.json"); + let mut preferences = Preferences { + favorites: Some(vec![]), + background: "forest".into(), + ..Preferences::default() + }; + preferences + .input_modes + .insert("com.webos.app.hdmi1".into(), InputMode::Off); + storage::save(&path, &preferences).unwrap(); + let loaded = storage::load(&path).unwrap(); + assert_eq!(loaded.favorites, Some(vec![])); + assert_eq!(loaded.background, "forest"); + assert_eq!(loaded.input_modes["com.webos.app.hdmi1"], InputMode::Off); + std::fs::write(&path, "broken").unwrap(); + assert!(storage::load(&path).is_err()); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn gallery_and_grid_navigation_scroll_to_keep_focus_visible() { + let mut state = ready(); + state.open(3); + state.section = 1; + state.focus = Target::Background(0); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Background(3)); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Background(6)); + assert_eq!( + state.scroll, 0., + "the web-sized gallery fits all three rows" + ); + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::Sidebar(1)); + state.open(1); + let last = state.apps().last().unwrap().id.clone(); + state.focus(Target::App(last), &controls(&state)); + assert!( + state.scroll > 0., + "library focus includes its wrapped caption" + ); +} + +#[test] +fn cancelling_options_preserves_identity_and_does_not_save() { + let mut state = ready(); + state.focus = Target::App("netflix".into()); + state.options(); + assert!(matches!(state.overlay, Some(Overlay::Options { .. }))); + state.focus = Target::CloseDialog; + assert!(state.activate().is_empty()); + assert_eq!(state.focus, Target::App("netflix".into())); + assert!(state.overlay.is_none()); +} + +#[test] +fn pointer_focus_does_not_scroll_the_clicked_control() { + let mut state = ready(); + state.open(1); + state.scroll = 150.; + state.pointer_focus(Target::App("netflix".into())); + assert_eq!(state.scroll, 150.); + state.focus(Target::Nav(1), &controls(&state)); + assert_eq!(state.scroll, 0.); +} + +#[test] +fn gallery_right_crosses_rows_in_gallery_order() { + let mut state = ready(); + state.open(3); + state.section = 1; + state.focus = Target::Background(2); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.focus, Target::Background(3)); +} + +#[test] +fn reordering_skips_hidden_and_unavailable_favorite_slots() { + let mut state = ready(); + state.preferences.favorites = Some(vec![ + "netflix".into(), + "missing".into(), + "youtube.leanback.v4".into(), + "amazon".into(), + ]); + state.preferences.hidden.push("youtube.leanback.v4".into()); + state.focus = Target::App("netflix".into()); + state.hold(); + state.direction(Direction::Right, &controls(&state)); + assert_eq!( + state.preferences.favorites.unwrap(), + vec!["amazon", "missing", "youtube.leanback.v4", "netflix"] + ); + assert_eq!(state.focus, Target::App("netflix".into())); +} + +#[test] +fn focused_navigation_tabs_switch_pages_immediately() { + let mut state = ready(); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.page, Page::Apps); + assert_eq!(state.focus, Target::Nav(1)); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.page, Page::Ha); + assert_eq!(state.focus, Target::Nav(2)); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.page, Page::Settings); + assert_eq!(state.focus, Target::Nav(3)); +} + +#[test] +fn greeting_subpage_opens_the_keyboard_and_back_restores_its_parent() { + let mut state = ready(); + state.open(3); + state.focus = Target::Setting(6); + assert!( + state + .direction(Direction::Right, &controls(&state)) + .is_empty() + ); + assert_eq!(state.subpage, Some(crate::state::Subpage::Greeting)); + assert_eq!(state.focus, Target::GreetingName); + assert!(!state.text_input_active()); + assert!(state.activate().is_empty()); + assert!(state.text_input_active()); + state.text("Ana"); + assert_eq!(state.editing_text(), "Ana"); + assert!(state.preferences.greeting_name.is_empty()); + assert_eq!(state.finish_text_input(), vec![Effect::Save]); + assert_eq!(state.focus, Target::GreetingName); + assert!(state.finish_text_input().is_empty()); + assert_eq!( + crate::i18n::Language::En.greeting(9, &state.preferences.greeting_name), + "Good morning, Ana." + ); + assert_eq!(state.subpage, Some(crate::state::Subpage::Greeting)); + assert!(state.back().is_empty()); + assert!(state.subpage.is_none()); + assert_eq!(state.focus, Target::Setting(6)); +} + +#[test] +fn greeting_name_replacement_commits_on_every_keyboard_completion_route() { + for route in 0..3 { + let mut state = ready(); + state.preferences.greeting_name = "Ana".into(); + state.open(3); + state.focus = Target::Setting(6); + state.activate(); + state.activate(); + assert!(state.editing_text().is_empty()); + state.text(" BobX "); + state.delete_surrounding_text(-3, 3); + assert_eq!(state.editing_text(), " Bo"); + state.text("b "); + assert_eq!(state.preferences.greeting_name, "Ana"); + let effects = match route { + 0 => { + state.overlay = Some(Overlay::SystemKeyboard); + state.finish_text_input() + } + 1 => state.back(), + _ => { + state.focus = Target::Key(crate::state::KEYS.len() - 1); + state.activate() + } + }; + assert_eq!(effects, vec![Effect::Save]); + assert_eq!(state.preferences.greeting_name, "Bob"); + assert_eq!(state.focus, Target::GreetingName); + assert!(state.finish_text_input().is_empty()); + state.activate(); + state.text(" "); + assert!(state.back().is_empty()); + assert_eq!(state.preferences.greeting_name, "Bob"); + } +} + +#[test] +fn remote_can_clear_a_saved_greeting_name_without_moving_the_controls() { + let mut state = ready(); + state.preferences.greeting_name = "Ana".into(); + state.open(3); + state.focus = Target::Setting(6); + state.activate(); + let before: Vec<_> = controls(&state) + .iter() + .map(|c| (c.target.clone(), c.rect.y)) + .collect(); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::GreetingNameClear); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(state.preferences.greeting_name.is_empty()); + assert_eq!(state.focus, Target::GreetingName); + let after: Vec<_> = controls(&state) + .iter() + .map(|c| (c.target.clone(), c.rect.y)) + .collect(); + assert_eq!(before, after); + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::Setting(6)); + assert!(state.subpage.is_none()); +} + +#[test] +fn app_name_settings_use_independent_defaults_including_with_legacy_preferences() { + let old: Preferences = serde_json::from_value(json!({"show_names": true})).unwrap(); + assert!(!old.show_home_names); + assert!(old.show_app_names); + let mut state = ready(); + assert!(!state.preferences.show_home_names); + assert!(state.preferences.show_app_names); + state.open(3); + state.focus = Target::Setting(0); + assert!( + state + .direction(Direction::Right, &controls(&state)) + .is_empty() + ); + assert_eq!(state.subpage, Some(crate::state::Subpage::AppNames)); + assert_eq!(state.focus, Target::AppNamesHome); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(state.preferences.show_home_names); + assert!(state.preferences.show_app_names); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::AppNamesApps); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(state.preferences.show_home_names); + assert!(!state.preferences.show_app_names); + assert_eq!(state.subpage, Some(crate::state::Subpage::AppNames)); + let restored: Preferences = + serde_json::from_value(serde_json::to_value(&state.preferences).unwrap()).unwrap(); + assert!(restored.show_home_names); + assert!(!restored.show_app_names); + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::Setting(0)); + assert!(state.subpage.is_none()); +} + +#[test] +fn home_and_apps_render_only_their_own_caption_preference() { + let mut state = ready(); + for (home, apps) in [(false, true), (true, false), (true, true), (false, false)] { + state.preferences.show_home_names = home; + state.preferences.show_app_names = apps; + for (page, expected) in [(0, home), (1, apps)] { + state.open(page); + let scene = scene(&state); + let caption = scene.draw.iter().any( + |draw| matches!(draw, crate::renderer::Draw::Text(value, ..) if value == "Netflix"), + ); + assert_eq!(caption, expected, "Home={home}, Apps={apps}, page={page}"); + let card = scene + .controls + .iter() + .find(|c| c.target == Target::App("netflix".into())) + .unwrap(); + assert_eq!( + card.rect.h > 154.4, + expected, + "Hidden captions must not reserve space" + ); + } + } +} + +#[test] +fn input_name_actions_only_highlight_the_focused_row_and_keep_the_saved_mode() { + let mut state = ready(); + state.preferences.reduced_motion = true; + state + .preferences + .set_input_name("com.webos.app.hdmi1", "Console"); + state.open(3); + state.section = crate::state::INPUTS_SECTION; + state.focus = Target::InputMode("com.webos.app.hdmi1".into()); + state.activate(); + for target in [Target::InputRename, Target::InputNameReset] { + state.pointer_focus(target.clone()); + let scene = scene(&state); + let highlighted: Vec<_> = scene + .draw + .iter() + .filter_map(|draw| match draw { + crate::renderer::Draw::RoundedGradient(rect, ..) + if rect.x == 640. && rect.w == 1120. => + { + Some(rect.y) + } + _ => None, + }) + .collect(); + let focused = scene.controls.iter().find(|c| c.target == target).unwrap(); + assert_eq!(highlighted, vec![focused.rect.y]); + assert!(scene.draw.iter().any(|draw| matches!(draw, crate::renderer::Draw::TintedImage(path, ..) if path.ends_with("check.lvec")))); + assert_eq!( + state + .preferences + .input_modes + .get("com.webos.app.hdmi1") + .copied() + .unwrap_or_default(), + InputMode::Auto + ); + assert!(scene.draw.iter().any( + |draw| matches!(draw, crate::renderer::Draw::Text(value, ..) if value == "Clear name") + )); + } +} + +#[test] +fn clock_choices_only_highlight_focus_while_preserving_saved_checkmarks() { + let mut state = ready(); + state.preferences.reduced_motion = true; + state.preferences.show_seconds = true; + state.open(3); + state.focus = Target::Setting(1); + state.activate(); + state.activate(); + for index in 0..2 { + state.pointer_focus(Target::Choice(index)); + let scene = scene(&state); + let highlighted: Vec<_> = scene + .draw + .iter() + .filter_map(|draw| match draw { + crate::renderer::Draw::RoundedGradient(rect, ..) + if rect.x == 640. && rect.w == 1120. => + { + Some(rect.y) + } + _ => None, + }) + .collect(); + let focused = scene + .controls + .iter() + .find(|c| c.target == Target::Choice(index)) + .unwrap(); + assert_eq!(highlighted, vec![focused.rect.y]); + let checks = scene.draw.iter().filter(|draw| matches!(draw, crate::renderer::Draw::TintedImage(path, ..) if path.ends_with("check.lvec"))).count(); + assert_eq!(checks, 1); + assert!(!state.preferences.hour_24); + assert!(state.preferences.show_seconds); + } +} + +#[test] +fn clock_format_subpage_saves_and_restores_its_parent_row() { + let mut state = ready(); + state.open(3); + state.focus = Target::Setting(1); + assert!( + state + .direction(Direction::Right, &controls(&state)) + .is_empty() + ); + assert_eq!(state.subpage, Some(crate::state::Subpage::Clock)); + assert_eq!(state.focus, Target::ClockFormat); + assert!( + state + .direction(Direction::Right, &controls(&state)) + .is_empty() + ); + assert_eq!(state.subpage, Some(crate::state::Subpage::ClockFormat)); + assert_eq!(state.focus, Target::Choice(0)); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(state.preferences.hour_24); + assert!(!state.preferences.show_seconds); + assert_eq!(state.subpage, Some(crate::state::Subpage::Clock)); + assert_eq!(state.focus, Target::ClockFormat); + state.direction(Direction::Left, &controls(&state)); + assert!(state.subpage.is_none()); + assert_eq!(state.focus, Target::Setting(1)); + state.activate(); + state.activate(); + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.subpage, Some(crate::state::Subpage::Clock)); + assert_eq!(state.focus, Target::ClockFormat); + state.back(); + assert_eq!(state.focus, Target::Setting(1)); + assert!(state.subpage.is_none()); +} + +#[test] +fn time_format_selects_seconds_without_changing_the_hour_format() { + let mut state = ready(); + state.preferences.hour_24 = true; + state.open(3); + state.focus = Target::Setting(1); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.subpage, Some(crate::state::Subpage::Clock)); + assert!(!state.preferences.show_seconds); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::ClockTimeFormat); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.subpage, Some(crate::state::Subpage::TimeFormat)); + assert_eq!(state.focus, Target::Choice(0)); + assert!( + !controls(&state) + .iter() + .any(|c| c.target == Target::Choice(2)) + ); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Choice(1)); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(state.preferences.show_seconds); + assert!(state.preferences.hour_24); + assert_eq!(state.subpage, Some(crate::state::Subpage::Clock)); + assert_eq!(state.focus, Target::ClockTimeFormat); + state.activate(); + assert_eq!(state.focus, Target::Choice(1)); + state.back(); + assert_eq!(state.subpage, Some(crate::state::Subpage::Clock)); + assert_eq!(state.focus, Target::ClockTimeFormat); + state.activate(); + state.direction(Direction::Up, &controls(&state)); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(!state.preferences.show_seconds); + assert!(state.preferences.hour_24); + state.back(); + assert!(state.subpage.is_none()); + assert_eq!(state.focus, Target::Setting(1)); +} + +#[test] +fn system_category_owns_refresh_and_quit_and_restores_sidebar_focus() { + let mut state = ready(); + state.open(3); + assert!( + !controls(&state) + .iter() + .any(|c| matches!(c.target, Target::Setting(3 | 5))) + ); + assert!( + controls(&state) + .iter() + .any(|c| c.target == Target::Sidebar(crate::state::SYSTEM_SECTION)) + ); + state.focus = Target::Sidebar(crate::state::SYSTEM_SECTION - 1); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Sidebar(crate::state::SYSTEM_SECTION)); + assert_eq!(state.section, crate::state::SYSTEM_SECTION); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.focus, Target::Setting(3)); + assert_eq!(state.activate(), vec![Effect::Reload]); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Setting(5)); + assert_eq!(state.activate(), vec![Effect::Quit]); + state.back(); + assert_eq!(state.focus, Target::Sidebar(crate::state::SYSTEM_SECTION)); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Sidebar(0)); + assert_eq!(state.section, 0); +} + +#[test] +fn input_visibility_has_explicit_choices_and_returns_to_the_same_input() { + let mut state = ready(); + state.open(3); + state.section = crate::state::INPUTS_SECTION; + let id = "com.webos.app.hdmi1".to_string(); + state.focus = Target::InputMode(id.clone()); + state.activate(); + assert_eq!(state.focus, Target::Choice(2)); + state.direction(Direction::Up, &controls(&state)); + assert_eq!(state.activate(), vec![Effect::Save]); + assert_eq!(state.preferences.input_modes[&id], InputMode::Off); + assert_eq!(state.focus, Target::InputMode(id.clone())); + assert!(!state.preferences.input_visible(&state.inventory.inputs[0])); + state.activate(); + state.back(); + assert_eq!(state.focus, Target::InputMode(id)); + assert!(state.subpage.is_none()); +} + +#[test] +fn background_entry_restores_the_applied_background() { + let mut state = ready(); + state.open(3); + state.preferences.background = "forest".into(); + state.focus = Target::Sidebar(1); + state.section = 1; + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.focus, Target::Background(6)); + state.back(); + state.activate(); + assert_eq!(state.focus, Target::Background(6)); +} + +#[test] +fn categories_and_search_have_clear_actions_and_conditional_hidden_control() { + let mut state = ready(); + state.open(1); + assert!( + !controls(&state) + .iter() + .any(|c| c.target == Target::Filter(5)) + ); + state.focus = Target::Search; + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::Filter(4)); + state.preferences.hidden.push("netflix".into()); + state.focus = Target::Search; + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::Filter(5)); + state.activate(); + assert_eq!(state.apps().len(), 1); + state.focus = Target::ClearFilter; + state.activate(); + assert_eq!(state.filter, 0); + assert_eq!(state.focus, Target::Filter(1)); + state.query = "YouTube".into(); + state.focus = Target::ClearSearch; + state.activate(); + assert!(state.query.is_empty()); + assert_eq!(state.focus, Target::Search); +} + +#[test] +fn a_notice_does_not_escape_modal_focus_and_closing_restores_the_app() { + let mut state = ready(); + state.open(1); + state.notify("Saved"); + state.focus = Target::App("netflix".into()); + state.options(); + assert!( + controls(&state) + .iter() + .all(|c| matches!(c.target, Target::Option(_) | Target::CloseDialog)) + ); + state.focus = Target::Option(1); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::Option(1)); + state.back(); + assert_eq!(state.focus, Target::App("netflix".into())); +} + +#[test] +fn remote_toolbar_focus_normalizes_scroll_but_hover_does_not() { + let mut state = ready(); + state.open(1); + state.scroll = 100.; + state.pointer_focus(Target::Filter(1)); + assert_eq!(state.scroll, 100.); + state.focus(Target::Filter(2), &controls(&state)); + assert_eq!(state.scroll, 0.); +} + +#[test] +fn long_captions_expand_rows_without_overlapping_the_next_row() { + let mut state = ready(); + state.open(1); + state.inventory.apps.iter_mut().find(|app| app.id == "netflix").unwrap().title = "A very long installed application title that needs more than two lines to remain fully readable".into(); + let targets = controls(&state); + let title = targets + .iter() + .find(|c| c.target == Target::App("netflix".into())) + .unwrap(); + let next_id = state.apps()[6].id.clone(); + let next = targets + .iter() + .find(|c| c.target == Target::App(next_id.clone())) + .unwrap(); + assert!(title.rect.h > 240.); + assert!(next.rect.y > title.rect.y + title.rect.h); +} + +#[test] +fn reduced_motion_disables_panel_wallpaper_and_notification_animation() { + let mut state = ready(); + state.preferences.reduced_motion = true; + state.open(3); + state.focus = Target::Setting(1); + state.activate(); + assert!(!state.animating()); + state.back(); + state.focus = Target::Background(6); + state.activate(); + state.notify("Saved"); + assert!(!state.animating()); +} + +#[test] +fn pinning_a_hidden_app_restores_it_and_leaves_hidden_when_empty() { + let mut state = ready(); + state.open(1); + state.filter = 5; + state.preferences.hidden.push("org.jellyfin.webos".into()); + state.focus = Target::App("org.jellyfin.webos".into()); + state.options(); + state.activate(); + assert!(state.preferences.hidden.is_empty()); + assert_eq!(state.filter, 0); + assert!( + state + .preferences + .favorites(&state.inventory.apps) + .contains(&"org.jellyfin.webos".into()) + ); + assert_eq!(state.focus, Target::App("org.jellyfin.webos".into())); +} + +#[test] +fn weather_location_keyboard_back_and_sidebar_routes_restore_focus() { + let mut state = ready(); + state.open(3); + state.focus = Target::Sidebar(crate::state::WEATHER_SECTION); + state.section = crate::state::WEATHER_SECTION; + state.activate(); + assert_eq!(state.focus, Target::WeatherToggle); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::WeatherLocation); + state.activate(); + assert_eq!(state.focus, Target::WeatherQuery); + state.activate(); + state.text("Vienna"); + state.back(); + assert_eq!(state.focus, Target::WeatherSearch); + assert_eq!(state.weather.query, "Vienna"); + assert_eq!(state.subpage, Some(crate::state::Subpage::WeatherLocation)); + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::WeatherLocation); + assert!(state.subpage.is_none()); +} + +#[test] +fn weather_units_save_without_replacing_celsius_cache_and_hide_keeps_city() { + let mut state = ready(); + state.open(3); + state.section = crate::state::WEATHER_SECTION; + let place = crate::weather::Location { + name: "Vienna".into(), + region: "Vienna".into(), + country: "Austria".into(), + latitude: 48.2085, + longitude: 16.3721, + }; + state.preferences.weather_location = Some(place.clone()); + state.weather.cache = Some(crate::weather::Cache { + key: place.key(), + updated_at: 1000, + reading: crate::weather::Reading { + temperature: 14.2, + code: 2, + is_day: true, + }, + }); + state.focus = Target::WeatherTemperature; + state.activate(); + assert_eq!(state.focus, Target::Choice(0)); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.activate(), vec![Effect::Save]); + assert_eq!( + state.preferences.temperature_unit, + crate::weather::Unit::Fahrenheit + ); + assert_eq!( + state.weather.cache.as_ref().unwrap().reading.temperature, + 14.2 + ); + assert_eq!(state.focus, Target::WeatherTemperature); + state.focus = Target::WeatherToggle; + state.activate(); + assert!(!state.preferences.show_weather); + assert_eq!(state.preferences.weather_location, Some(place)); +} + +#[test] +fn removing_weather_location_clears_cache_and_cancels_pending_results() { + let mut state = ready(); + state.open(3); + state.section = crate::state::WEATHER_SECTION; + state.focus = Target::WeatherLocation; + state.activate(); + state.focus = Target::WeatherClear; + let effects = state.activate(); + assert_eq!( + effects, + vec![ + Effect::Save, + Effect::Weather(crate::weather::Command::ClearCache) + ] + ); + assert!(state.weather.cache.is_none()); + assert!(state.preferences.weather_location.is_none()); + assert_eq!(state.focus, Target::WeatherSearch); +} + +#[test] +fn language_selection_restores_its_parent_and_is_saved_with_preferences() { + let mut state = ready(); + state.open(3); + state.focus = Target::Setting(4); + state.direction(Direction::Right, &controls(&state)); + assert_eq!(state.subpage, Some(crate::state::Subpage::Language)); + assert_eq!(state.focus, Target::Choice(0)); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.activate(), vec![Effect::Save]); + assert_eq!(state.preferences.language, crate::i18n::Language::De); + assert_eq!(state.focus, Target::Setting(4)); + state.activate(); + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::Setting(4)); +} + +#[test] +fn remote_can_select_and_restore_every_language() { + for (index, language) in crate::i18n::Language::ALL.into_iter().enumerate() { + let mut state = ready(); + state.open(3); + state.focus = Target::Setting(4); + state.activate(); + for _ in 0..index { + state.direction(Direction::Down, &controls(&state)); + } + assert_eq!(state.focus, Target::Choice(index)); + assert_eq!(state.activate(), vec![Effect::Save]); + assert_eq!(state.preferences.language, language); + assert_eq!(state.focus, Target::Setting(4)); + + let saved = serde_json::to_value(&state.preferences).unwrap(); + let mut restored = State::new(serde_json::from_value(saved).unwrap()); + restored.open(3); + restored.focus = Target::Setting(4); + restored.activate(); + assert_eq!(restored.preferences.language, language); + assert_eq!(restored.focus, Target::Choice(index)); + restored.direction(Direction::Down, &controls(&restored)); + assert_eq!( + restored.focus, + Target::Choice((index + 1) % crate::i18n::Language::ALL.len()) + ); + } +} + +#[test] +fn unsupported_language_preferences_recover_without_losing_other_choices() { + let preferences: Preferences = serde_json::from_value( + json!({"language":"unknown","favorites":["netflix"],"show_home_names":true}), + ) + .unwrap(); + assert_eq!(preferences.language, crate::i18n::Language::En); + assert!(preferences.show_home_names); + assert_eq!(preferences.favorites, Some(vec!["netflix".into()])); +} + +#[test] +fn unrelated_app_ids_and_titles_keep_their_installed_artwork() { + for (id, title) in [ + ("com.tvweb.dashboard", "Glasshouse"), + ("com.example.ardent", "Notes"), + ("com.example.3ard", "Notes"), + ("com.example.ard3", "Notes"), + ("com.example.plexus", "Notes"), + ("com.example.media", "Standard dashboard"), + ("com.example.media", "netflixer"), + ("com.example.media", "preNetflix"), + ("amazon.alexa.view", "Amazon Alexa"), + ("com.example.spotify", "Spotify tools"), + ("com.example.media", "Plex"), + ("com.example.browser", "Web Browser"), + ("com.example.livetv", "Live TV"), + ] { + let app = App::new(id, title); + assert_eq!( + (app.brand.as_str(), app.category.as_str()), + ("generic", "Utilities"), + "An unrelated application must not be rebranded: {id} / {title}" + ); + } +} + +#[test] +fn curated_branding_does_not_use_title_words_or_partial_id_matches() { + for (id, title) in [ + ("com.example.ard_more", "Notes"), + ("com.example.more-ard", "Notes"), + ("com.example.ARD", "Notes"), + ("com.example.media", "ARD"), + ("com.example.media", "(ARD)"), + ("com.example.media", "ARD미디어테크"), + ("com.example.media", "아에르데ARD"), + ("com.example.media", "아에르데ARD미디어테크"), + ("com.example.dashboard", "ARD"), + ("com.example.media", "standard ardent ARD"), + ] { + let app = App::new(id, title); + assert_eq!( + (app.brand.as_str(), app.category.as_str()), + ("generic", "Utilities"), + "Only an exact app ID may select curated artwork: {id} / {title}" + ); + } +} + +#[test] +fn known_app_ids_preserve_the_curated_brands_and_categories() { + let apps = demo_inventory().apps; + let expected = [ + ("com.webos.app.livetv", "live-tv", "Streaming"), + ("netflix", "netflix", "Streaming"), + ("youtube.leanback.v4", "youtube", "Streaming"), + ("com.apple.appletv", "apple-tv", "Streaming"), + ("com.disney.disneyplus-prod", "disney-plus", "Streaming"), + ("amazon", "prime-video", "Streaming"), + ("plex", "plex", "Streaming"), + ("spotify-beehive", "spotify", "Music"), + ("org.jellyfin.webos", "jellyfin", "Streaming"), + ("org.xbmc.kodi", "kodi", "Streaming"), + ("com.limelight.webos", "moonlight", "Gaming"), + ("org.webosbrew.hbchannel", "homebrew", "Utilities"), + ("com.webos.app.browser", "browser", "Utilities"), + ("com.arte.tv", "arte", "Streaming"), + ("de.zdf.app.zdfm3", "zdf", "Streaming"), + ("ard.mediathek", "das-erste", "Streaming"), + ]; + for (id, brand, category) in expected { + let app = apps.iter().find(|app| app.id == id).unwrap(); + assert_eq!( + (app.brand.as_str(), app.category.as_str()), + (brand, category), + "Known app {id} must retain its curated brand and category" + ); + } +} + +#[test] +fn hdmi_plug_in_reports_the_cable_state_when_connected_only_means_present() { + let inputs = normalize_inputs(&json!([ + {"id":"HDMI_1","label":"HDMI 1","port":1,"connected":true,"appId":"com.webos.app.hdmi1","hdmiPlugIn":false}, + {"id":"HDMI_2","label":"AVR-X1800H","port":2,"connected":true,"appId":"com.webos.app.hdmi2","hdmiPlugIn":true}, + {"id":"HDMI_3","label":"HDMI 3","port":3,"connected":true,"appId":"com.webos.app.hdmi3","hdmiPlugIn":false}, + {"id":"HDMI_4","label":"HDMI 4","port":4,"connected":true,"appId":"com.webos.app.hdmi4","hdmiPlugIn":false} + ])) + .unwrap(); + let connected: Vec<_> = inputs + .iter() + .map(|input| (input.id.as_str(), input.connected)) + .collect(); + assert_eq!( + connected, + [ + ("com.webos.app.hdmi1", Some(false)), + ("com.webos.app.hdmi2", Some(true)), + ("com.webos.app.hdmi3", Some(false)), + ("com.webos.app.hdmi4", Some(false)), + ] + ); + assert_eq!(inputs[1].title, "AVR-X1800H"); + let preferences = Preferences::default(); + let visible: Vec<_> = inputs + .iter() + .filter(|input| preferences.input_visible(input)) + .map(|input| input.id.as_str()) + .collect(); + assert_eq!(visible, ["com.webos.app.hdmi2"]); +} + +#[test] +fn inputs_without_hdmi_plug_in_keep_the_connected_report() { + let inputs = normalize_inputs(&json!([ + {"appId":"com.webos.app.hdmi1","id":"HDMI_1","port":1,"connected":true}, + {"appId":"com.webos.app.hdmi2","id":"HDMI_2","port":2,"connected":false}, + {"appId":"com.webos.app.hdmi3","connected":true,"hdmiPlugIn":"yes"}, + {"appId":"com.webos.app.hdmi4"} + ])) + .unwrap(); + let connected: Vec<_> = inputs.iter().map(|input| input.connected).collect(); + assert_eq!(connected, [Some(true), Some(false), Some(true), None]); +} + +#[test] +fn input_names_are_sanitized_and_old_preferences_load_without_them() { + let old: Preferences = serde_json::from_value(json!({"background": "forest"})).unwrap(); + assert!(old.input_names.is_empty()); + let mut preferences: Preferences = serde_json::from_value(json!({ + "input_names": { + "com.webos.app.hdmi1": " Play\nStation\t5 ", + "com.webos.app.hdmi2": " ", + "bad;id": "Ignored", + "com.webos.app.hdmi3": "A".repeat(60), + } + })) + .unwrap(); + preferences.sanitize(); + assert_eq!( + preferences.input_names, + BTreeMap::from([ + ( + "com.webos.app.hdmi1".to_string(), + "PlayStation5".to_string() + ), + ( + "com.webos.app.hdmi3".to_string(), + "A".repeat(crate::model::INPUT_NAME_LIMIT) + ), + ]) + ); + assert!(preferences.set_input_name("com.webos.app.hdmi2", " DENON ")); + assert!(!preferences.set_input_name("com.webos.app.hdmi2", "DENON")); + assert_eq!(preferences.input_names["com.webos.app.hdmi2"], "DENON"); + assert!(preferences.set_input_name("com.webos.app.hdmi2", "")); + assert!(!preferences.input_names.contains_key("com.webos.app.hdmi2")); + assert!(!preferences.set_input_name("bad;id", "Name")); +} + +#[test] +fn custom_input_names_keep_the_label_port_and_status_as_detail() { + let mut preferences = Preferences::default(); + let input = Input { + id: "com.webos.app.hdmi2".into(), + title: "AVR-X1800H".into(), + port: "HDMI 2".into(), + connected: Some(true), + }; + let language = preferences.language; + let status = |preferences: &Preferences| { + view::input_status(&input, preferences.input_name(&input).is_some(), language) + }; + assert_eq!(preferences.input_title(&input), "HDMI 2"); + assert_eq!(status(&preferences), "AVR-X1800H · Connected"); + preferences.set_input_name(&input.id, "DENON"); + assert_eq!(preferences.input_title(&input), "DENON"); + assert_eq!(status(&preferences), "AVR-X1800H · HDMI 2 · Connected"); + let plain = Input { + title: "HDMI 2".into(), + ..input.clone() + }; + assert_eq!( + view::input_status(&plain, true, language), + "HDMI 2 · Connected" + ); + assert_eq!(view::input_status(&plain, false, language), "Connected"); +} + +#[test] +fn remote_renames_and_resets_an_input_from_its_settings_page() { + let mut state = ready(); + state.open(3); + state.section = crate::state::INPUTS_SECTION; + let id = "com.webos.app.hdmi2".to_string(); + state.focus = Target::InputMode(id.clone()); + state.activate(); + assert_eq!(state.focus, Target::Choice(2)); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::InputRename); + assert!( + !controls(&state) + .iter() + .any(|c| c.target == Target::InputNameReset) + ); + state.activate(); + assert!(matches!(state.overlay, Some(Overlay::Keyboard))); + state.text("DENONX"); + state.delete(); + assert_eq!(state.back(), vec![Effect::Save]); + assert_eq!(state.focus, Target::InputRename); + assert_eq!(state.preferences.input_names[&id], "DENON"); + assert_eq!( + state.preferences.input_title(&state.inventory.inputs[1]), + "DENON" + ); + // Dismissing an empty keyboard keeps the saved name. + state.activate(); + assert!(state.finish_text_input().is_empty()); + assert_eq!(state.preferences.input_names[&id], "DENON"); + state.direction(Direction::Down, &controls(&state)); + assert_eq!(state.focus, Target::InputNameReset); + assert_eq!(state.activate(), vec![Effect::Save]); + assert!(state.preferences.input_names.is_empty()); + assert_eq!(state.focus, Target::InputRename); + state.direction(Direction::Up, &controls(&state)); + assert_eq!(state.focus, Target::Choice(2)); + state.focus = Target::InputRename; + state.direction(Direction::Left, &controls(&state)); + assert_eq!(state.focus, Target::InputMode(id)); + assert!(state.subpage.is_none()); +} + +#[test] +fn keyboard_done_saves_a_typed_input_name() { + let mut state = ready(); + state.open(3); + state.section = crate::state::INPUTS_SECTION; + state.focus = Target::InputMode("com.webos.app.hdmi1".into()); + state.activate(); + state.focus = Target::InputRename; + state.activate(); + state.text("PS5"); + state.focus = Target::Key( + crate::state::KEYS + .iter() + .position(|k| *k == "Done") + .unwrap(), + ); + assert_eq!(state.activate(), vec![Effect::Save]); + assert_eq!(state.preferences.input_names["com.webos.app.hdmi1"], "PS5"); + assert!(state.overlay.is_none()); +} diff --git a/src/vector.rs b/src/vector.rs new file mode 100644 index 0000000..3425767 --- /dev/null +++ b/src/vector.rs @@ -0,0 +1,84 @@ +//! Precompiled SVG triangle geometry, uploaded once without image textures. +use gl::types::GLuint; +use std::{fs, path::Path}; + +pub struct Mesh { + pub buffer: GLuint, + pub width: f32, + pub height: f32, + pub vertices: i32, + pub bytes: usize, +} + +impl Mesh { + pub fn load(path: &Path) -> Result { + let bytes = + fs::read(path).map_err(|e| format!("Vector artwork {}: {e}", path.display()))?; + if bytes.len() < 20 || &bytes[..8] != b"LUMAVEC1" { + return Err(format!("Invalid vector artwork header: {}", path.display())); + } + let float = |offset| f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let width = float(8); + let height = float(12); + let count = u32::from_le_bytes(bytes[16..20].try_into().unwrap()) as usize; + if !width.is_finite() + || !height.is_finite() + || width <= 0. + || height <= 0. + || count == 0 + || count > 1_000_000 + || !count.is_multiple_of(3) + || bytes.len() != 20 + count * 36 + { + return Err(format!( + "Invalid vector artwork dimensions: {}", + path.display() + )); + } + let vertices: Vec = bytes[20..] + .chunks_exact(4) + .map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap())) + .collect(); + if vertices.iter().any(|value| !value.is_finite()) { + return Err(format!( + "Invalid vector artwork coordinates: {}", + path.display() + )); + } + Self::upload(width, height, &vertices) + } + + pub fn upload(width: f32, height: f32, vertices: &[f32]) -> Result { + let count = vertices.len() / 9; + if count == 0 || !count.is_multiple_of(3) || vertices.len() != count * 9 { + return Err("Invalid vector buffer vertex count".into()); + } + if vertices.iter().any(|value| !value.is_finite()) { + return Err("Invalid vector buffer coordinates".into()); + } + let mut buffer = 0; + unsafe { + gl::GenBuffers(1, &mut buffer); + gl::BindBuffer(gl::ARRAY_BUFFER, buffer); + gl::BufferData( + gl::ARRAY_BUFFER, + std::mem::size_of_val(vertices) as isize, + vertices.as_ptr().cast(), + gl::STATIC_DRAW, + ); + } + Ok(Self { + buffer, + width, + height, + vertices: count as i32, + bytes: count * 36, + }) + } +} + +impl Drop for Mesh { + fn drop(&mut self) { + unsafe { gl::DeleteBuffers(1, &self.buffer) }; + } +} diff --git a/src/view.rs b/src/view.rs new file mode 100644 index 0000000..bb4ea4c --- /dev/null +++ b/src/view.rs @@ -0,0 +1,2635 @@ +//! Screen geometry and styling mirror the web UI's 1920×1080 / 16px-rem layout. +use crate::{ + i18n::Language, + model::{App, BACKGROUNDS, CATEGORIES, Input, InputMode}, + renderer::{Color, Draw, TextStyle, WHITE}, + state::{ + APPS_COLUMNS, Control, HOME_TILE_PITCH, HOME_TILE_WIDTH, KEYS, Overlay, Page, Rect, + SYSTEM_SECTION, State, Subpage, Target, TextField, WEATHER_SECTION, + }, + weather::{self, Unit}, +}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; +use time::OffsetDateTime; + +const MAIN_TOP: f32 = 108.8; +const FACE_HEIGHT: f32 = 154.4; +const GRID_PITCH: f32 = 296.4; + +pub struct Assets { + pub root: PathBuf, + pub version: String, + pub metrics: BTreeMap>, + pub artwork: crate::app_artwork::Service, +} +impl Assets { + pub fn load(root: PathBuf, webos: bool) -> Result { + let load = |file: &str| { + std::fs::read(root.join(file)) + .map_err(|e| format!("Native assets are missing: {e}. Run pnpm assets.")) + }; + let version = String::from_utf8(load("version.txt")?).map_err(|e| e.to_string())?; + let metrics = + serde_json::from_slice(&load("font-metrics.json")?).map_err(|e| e.to_string())?; + Ok(Self { + root, + version, + metrics, + artwork: crate::app_artwork::Service::new(webos), + }) + } + fn path(&self, path: impl AsRef) -> String { + self.root.join(path).to_string_lossy().into_owned() + } + + fn width(&self, value: &str, style: TextStyle) -> f32 { + let key = format!( + "{}{}", + if style.size >= 32. { "display-" } else { "" }, + style.weight + ); + let advances = self.metrics.get(&key); + let fallback = self.metrics.get(if style.weight >= 500 { + "fallback-500" + } else { + "fallback-400" + }); + value + .chars() + .map(|c| { + (advances + .and_then(|a| a.get(&c)) + .or_else(|| fallback.and_then(|a| a.get(&c))) + .copied() + .unwrap_or(if c == ' ' { 0.28 } else { 0.53 }) + + style.tracking) + * style.size + }) + .sum() + } + + fn lines(&self, value: &str, style: TextStyle, width: f32) -> usize { + let key = format!( + "{}{}", + if style.size >= 32. { "display-" } else { "" }, + style.weight + ); + let advances = self.metrics.get(&key); + let fallback = self.metrics.get(if style.weight >= 500 { + "fallback-500" + } else { + "fallback-400" + }); + let advance = |c: char| { + (advances + .and_then(|a| a.get(&c)) + .or_else(|| fallback.and_then(|a| a.get(&c))) + .copied() + .unwrap_or(if c == ' ' { 0.28 } else { 0.53 }) + + style.tracking) + * style.size + }; + let mut lines = 1; + let mut used = 0.; + for word in value.split_inclusive(char::is_whitespace) { + let w: f32 = word.chars().map(advance).sum(); + if used > 0. && used + w > width { + lines += 1; + used = 0.; + } + for c in word.chars() { + if c == '\n' { + lines += 1; + used = 0.; + continue; + } + let w = advance(c); + if used > 0. && used + w > width { + lines += 1; + used = 0.; + } + used += w; + } + } + lines + } +} + +pub struct Scene { + pub draw: Vec, + pub controls: Vec, + pub modal: Option, +} +impl Scene { + fn new() -> Self { + Self { + draw: Vec::new(), + controls: Vec::new(), + modal: None, + } + } + fn shape(&mut self, rect: Rect, radius: f32, fill: Color) { + self.draw.push(Draw::Shape(rect, radius, 0., fill)); + } + fn border(&mut self, rect: Rect, radius: f32, width: f32, color: Color) { + self.draw.push(Draw::Shape(rect, radius, width, color)); + } + fn text( + &mut self, + text: impl Into, + rect: Rect, + style: TextStyle, + color: Color, + center: bool, + ) { + self.draw + .push(Draw::Text(text.into(), rect, style, color, center)); + } + fn image(&mut self, path: String, rect: Rect, radius: f32, contain: bool) { + self.draw.push(Draw::Image(path, rect, radius, contain)); + } + fn icon(&mut self, assets: &Assets, name: &str, rect: Rect, color: Color) { + self.draw.push(Draw::TintedImage( + assets.path(format!("ui-icons/{name}.lvec")), + rect, + 0., + true, + color, + )); + } + fn control(&mut self, target: Target, rect: Rect, scrollable: bool) { + self.controls.push(Control { + target, + rect, + scrollable, + }); + } + fn ring(&mut self, rect: Rect, radius: f32) { + self.border(rect.inset(-6.9), radius + 6.9, 3.7, c(0xffffff, 0.10)); + self.border(rect.inset(-3.2), radius + 3.2, 3.2, c(0xf0f0f0, 1.)); + } + fn shadow(&mut self, rect: Rect, radius: f32, blur: f32, y: f32, alpha: f32) { + self.draw.push(Draw::Shadow( + Rect::new(rect.x, rect.y + y, rect.w, rect.h), + radius, + blur, + c(0, alpha), + )); + } + fn pill(&mut self, rect: Rect, focused: bool, selected: bool) { + self.shadow(rect, rect.h / 2., 18., 4., 0.06); + if focused { + self.draw.push(Draw::RoundedGradient( + rect, + rect.h / 2., + c(0xfafafa, 1.), + c(0xe5e5e5, 1.), + )); + } else { + self.shape( + rect, + rect.h / 2., + if selected { + c(0x303438, 1.) + } else { + c(0x171c20, 0.72) + }, + ); + self.border(rect, rect.h / 2., 1., c(0xffffff, 0.07)); + } + } + fn fade(&mut self, from: usize, alpha: f32, brightness: f32) { + for draw in &mut self.draw[from..] { + let apply = |color: &mut Color| { + for channel in &mut color[..3] { + *channel *= brightness; + } + color[3] *= alpha; + }; + match draw { + Draw::Shape(_, _, _, color) + | Draw::Shadow(_, _, _, color) + | Draw::Text(_, _, _, color, _) + | Draw::TintedImage(_, _, _, _, color) + | Draw::AppImage(_, _, color) => apply(color), + Draw::RoundedGradient(_, _, a, b) => { + apply(a); + apply(b); + } + Draw::Image(path, rect, radius, contain) => { + *draw = Draw::TintedImage( + path.clone(), + *rect, + *radius, + *contain, + [brightness, brightness, brightness, alpha], + ); + } + Draw::Clip(_) | Draw::EdgeFade(_) => {} + } + } + } +} + +fn c(rgb: u32, alpha: f32) -> Color { + [ + ((rgb >> 16) & 255) as f32 / 255., + ((rgb >> 8) & 255) as f32 / 255., + (rgb & 255) as f32 / 255., + alpha, + ] +} +fn text(size: f32, weight: u16, tracking: f32) -> TextStyle { + TextStyle::new(size).weight(weight).tracking(tracking) +} + +pub struct Navigation { + pub surface: Rect, + pub items: [Rect; 4], +} +pub fn navigation(assets: &Assets, language: Language) -> Navigation { + let widths = ["start", "apps", "ha", "settings"] + .map(|key| (assets.width(language.t(key), text(20.8, 550, -0.02)) + 46.4).max(99.2)); + let width = widths.iter().sum::() + 38.4 + 19.6; + let mut x = (1920. - width) / 2. + 9.8; + let items = widths.map(|width| { + let rect = Rect::new(x, 52.3, width, 49.); + x += width + 12.8; + rect + }); + Navigation { + surface: Rect::new((1920. - width) / 2., 42.5, width, 68.6), + items, + } +} + +pub fn scene_with_pill( + state: &State, + assets: &Assets, + pill_x: f32, + pill_width: f32, + now: OffsetDateTime, +) -> Scene { + let mut out = Scene::new(); + if let Some((previous, start)) = &state.previous_background { + out.image( + assets.path(format!("wallpapers/{previous}.jpg")), + Rect::new(0., 0., 1920., 1080.), + 0., + false, + ); + let alpha = (start.elapsed().as_secs_f32() / 0.4).min(1.); + out.draw.push(Draw::TintedImage( + assets.path(format!("wallpapers/{}.jpg", state.preferences.background)), + Rect::new(0., 0., 1920., 1080.), + 0., + false, + [1., 1., 1., alpha], + )); + } else { + out.image( + assets.path(format!("wallpapers/{}.jpg", state.preferences.background)), + Rect::new(0., 0., 1920., 1080.), + 0., + false, + ); + } + let main_bottom = if state.page == Page::Home && state.moving.is_none() { + 1053.6 + } else { + 994.4 + }; + out.draw.push(Draw::Clip(Some(Rect::new( + 64., + MAIN_TOP, + 1792., + main_bottom - MAIN_TOP, + )))); + if state.page == Page::Apps { + out.draw.push(Draw::EdgeFade(Some([ + MAIN_TOP, + 152.8, + main_bottom - 24., + main_bottom, + ]))); + } + match state.page { + Page::Home => home(&mut out, state, assets, now), + Page::Apps => library(&mut out, state, assets), + Page::Ha => ha(&mut out, state, assets), + Page::Settings => settings(&mut out, state, assets), + } + if let Some(error) = &state.error + && !state.inventory.apps.is_empty() + && !out + .controls + .iter() + .any(|control| control.target == Target::Retry) + { + let rect = Rect::new(84., main_bottom - 66., 1752., 54.); + out.shape(rect, 11.2, c(0xad7c3e, 0.07)); + out.border(rect, 11.2, 1., c(0xedc596, 0.19)); + out.icon( + assets, + "alert", + Rect::new(104., rect.y + 18., 18., 18.), + c(0xf3ddbd, 1.), + ); + out.text( + error, + Rect::new(138., rect.y + 16., 1500., 35.), + text(14.4, 400, 0.), + c(0xf3ddbd, 1.), + false, + ); + let retry = Rect::new(1720., rect.y + 8., 96., 38.); + out.text( + state.preferences.language.t("retry"), + Rect::new(retry.x, retry.y + 10., retry.w, 20.), + text(14.4, 500, 0.), + c(0xf3ddbd, 1.), + true, + ); + if state.focus == Target::Retry { + out.border(retry, 6.4, 2., WHITE); + } + out.control(Target::Retry, retry, false); + } + out.draw.push(Draw::EdgeFade(None)); + out.draw.push(Draw::Clip(None)); + header(&mut out, state, assets, now, pill_x, pill_width); + footer(&mut out, state, assets); + if let Some(overlay @ (Overlay::Options { .. } | Overlay::Keyboard)) = &state.overlay { + out.controls.clear(); + let start = out.draw.len(); + out.shape(Rect::new(0., 0., 1920., 1080.), 0., c(0x080b12, 0.70)); + match overlay { + Overlay::Options { id, .. } => options(&mut out, state, assets, id), + Overlay::Keyboard => keyboard(&mut out, state, assets), + Overlay::SystemKeyboard => {} + } + if let Some(opened) = state.panel_started { + out.fade(start, (opened.elapsed().as_secs_f32() / 0.12).min(1.), 1.); + } + } + if let Some((notice, _)) = &state.notice { + toast(&mut out, state, assets, notice); + } + out +} + +fn header( + out: &mut Scene, + state: &State, + assets: &Assets, + now: OffsetDateTime, + pill_x: f32, + pill_width: f32, +) { + let language = state.preferences.language; + let clock = language.clock( + now, + state.preferences.hour_24, + state.preferences.show_seconds, + ); + out.text( + clock, + Rect::new(84., 64.8, 300., 24.), + text(20., 500, -0.03), + WHITE, + false, + ); + let layout = navigation(assets, language); + let nav = layout.surface; + out.shadow(nav, 48., 18., 4., 0.06); + out.shape(nav, 48., c(0x171c20, 0.72)); + out.border(nav, 48., 1., c(0xffffff, 0.07)); + let pill = Rect::new(pill_x, 52.3, pill_width, 49.); + out.draw.push(Draw::RoundedGradient( + pill, + 32., + c(0xfafafa, 1.), + c(0xe5e5e5, 1.), + )); + if matches!(state.focus, Target::Nav(_)) && !state.pointer { + // Follow the pill's animated position and width instead of jumping to + // the destination tab's control rectangle. + out.border(pill.inset(-5.), 37., 2., c(0xf0f0f0, 0.95)); + } + for (index, key) in ["start", "apps", "ha", "settings"].into_iter().enumerate() { + let rect = layout.items[index]; + out.text( + language.t(key), + Rect::new(rect.x, 64.3, rect.w, 25.), + text(20.8, 550, -0.02), + if state.page_index() == index { + c(0x2c2c2c, 1.) + } else { + c(0xeeeeee, 0.73) + }, + true, + ); + out.control(Target::Nav(index), rect, false); + } + let rect = Rect::new(1786.4, 52., 49.6, 49.6); + let focused = state.focus == Target::TvSettings; + out.pill(rect, focused, false); + out.icon( + assets, + "settings", + Rect::new(1799.6, 65.2, 23.2, 23.2), + if focused { + c(0x2c2c2c, 1.) + } else { + c(0xeeeeee, 0.73) + }, + ); + out.control(Target::TvSettings, rect, false); +} + +fn tile( + out: &mut Scene, + state: &State, + assets: &Assets, + app: &App, + rect: Rect, + names: bool, + pinned: bool, +) { + let caption_height = if names { + caption_height(state, assets, app, rect.w) + } else { + 0. + }; + let label_height = if names { 18.4 + caption_height } else { 0. }; + out.control( + Target::App(app.id.clone()), + Rect::new(rect.x, rect.y, rect.w, rect.h + label_height), + true, + ); + if rect.y + rect.h + label_height < MAIN_TOP + || rect.y > 1054. + || rect.x + rect.w + 40. < 64. + || rect.x - 40. > 1856. + { + return; + } + let start = out.draw.len(); + let focused = state.focus == Target::App(app.id.clone()); + out.shadow( + rect, + 19.2, + if focused { 34. } else { 22. }, + if focused { 14. } else { 9. }, + if focused { 0.4 } else { 0.165 }, + ); + let logo = crate::branding::for_key(&app.brand); + let artwork = logo + .is_none() + .then(|| { + app.icon + .as_ref() + .and_then(|source| assets.artwork.get(source)) + }) + .flatten(); + if let Some(rgb) = artwork.as_ref().map(|image| &image.color) { + out.shape( + rect, + 19.2, + [ + rgb[0] as f32 / 255., + rgb[1] as f32 / 255., + rgb[2] as f32 / 255., + 1., + ], + ); + } else { + out.image( + assets.path(format!("surfaces/{}.png", app.brand)), + rect, + 19.2, + false, + ); + } + out.border(rect, 19.2, 1., c(0xffffff, 0.063)); + if let Some(brand) = logo { + let (ratio, h) = brand.artwork_size(); + out.image( + assets.path(format!("logos/{}.lvec", app.brand)), + Rect::new( + rect.x + rect.w * (1. - ratio) / 2., + rect.y + (rect.h - h) / 2., + rect.w * ratio, + h, + ), + 0., + true, + ); + } else if let Some(image) = artwork { + out.draw.push(Draw::AppImage( + image, + Rect::new( + rect.x + rect.w * 0.16, + rect.y + rect.h * 0.16, + rect.w * 0.68, + rect.h * 0.68, + ), + [1.; 4], + )); + } else { + brand_mark(out, assets, app, rect, state.preferences.language); + } + if pinned { + out.icon( + assets, + "heart-filled", + Rect::new(rect.x + rect.w - 27.2, rect.y + 11.2, 16., 16.), + c(0xffffff, 0.9), + ); + } + if state.moving.as_ref() == Some(&app.id) { + let indicator = Rect::new( + rect.x + rect.w / 2. - 37.6, + rect.y + rect.h - 33.6, + 75.2, + 24., + ); + out.shape(indicator, 16., c(0x101318, 0.79)); + out.icon( + assets, + "left", + Rect::new(indicator.x + 8.8, indicator.y + 4., 16., 16.), + c(0xededed, 1.), + ); + out.icon( + assets, + "arrow", + Rect::new(indicator.x + 40., indicator.y + 4., 16., 16.), + c(0xededed, 1.), + ); + } + if focused { + out.ring(rect, 19.2); + } + if state.page == Page::Apps && state.filter == 5 { + out.fade(start, 0.45, 1.); + } + if state.pressed.as_ref() == Some(&Target::App(app.id.clone())) { + out.fade(start, 1., 0.85); + } + if names { + out.text( + format!( + "{}{}", + app.title, + if state.filter == 5 { + format!(" · {}", state.preferences.language.t("hiddenLabel")) + } else { + String::new() + } + ), + Rect::new( + rect.x + 4., + rect.y + rect.h + 18.4, + rect.w - 8., + caption_height, + ), + text(17.6, if focused { 550 } else { 450 }, -0.02).leading(1.4), + if focused { + c(0xffffff, 1.) + } else { + c(0xececec, 0.66) + }, + true, + ); + } + if state.moving.is_some() && state.moving.as_ref() != Some(&app.id) { + out.fade(start, 0.48, 1.); + } +} + +fn caption_height(state: &State, assets: &Assets, app: &App, width: f32) -> f32 { + let title = format!( + "{}{}", + app.title, + if state.filter == 5 { + format!(" · {}", state.preferences.language.t("hiddenLabel")) + } else { + String::new() + } + ); + let style = text( + 17.6, + if state.focus == Target::App(app.id.clone()) { + 550 + } else { + 450 + }, + -0.02, + ) + .leading(1.4); + (assets.lines(&title, style, width - 8.) as f32 * style.size * style.line_height).max(49.28) +} + +fn brand_mark(out: &mut Scene, assets: &Assets, app: &App, rect: Rect, language: Language) { + let (cx, cy) = rect.center(); + match app.brand.as_str() { + "plex" => { + out.text( + "ple", + Rect::new(cx - 63., cy - 35.2, 130., 70.4), + text(70.4, 650, -0.08).leading(1.), + WHITE, + false, + ); + out.text( + "x", + Rect::new(cx + 20., cy - 35.2, 60., 70.4), + text(70.4, 800, -0.08).leading(1.), + c(0xe6aa25, 1.), + false, + ); + } + "spotify" => { + out.icon( + assets, + "spotify", + Rect::new(cx - 77., cy - 20., 40., 40.), + c(0x5ae08c, 1.), + ); + out.text( + "Spotify", + Rect::new(cx - 29., cy - 14.4, 120., 36.), + text(28.8, 650, -0.04).leading(1.), + c(0x5ae08c, 1.), + false, + ); + } + "kodi" => { + out.icon( + assets, + "kodi", + Rect::new(cx - 76., cy - 22.5, 45., 45.), + WHITE, + ); + out.text( + "kodi", + Rect::new(cx - 18., cy - 24.8, 130., 60.), + text(49.6, 350, -0.04).leading(1.), + WHITE, + false, + ); + } + brand => { + let (icon, label) = match brand { + "live-tv" => ("tv", language.t("liveTV")), + "homebrew" => ("coffee", language.t("homebrew")), + "browser" => ("globe", language.t("browser")), + "moonlight" => ("moonlight", "Moonlight"), + "gaming" => ("gaming", app.title.as_str()), + _ => ("generic", app.title.as_str()), + }; + let font = if brand == "generic" { + text(18.4, 500, -0.035).leading(1.35) + } else { + text(24., 500, -0.035).leading(1.2) + }; + let w = (assets.width(label, font) + 54.4).min(rect.w - 32.); + let label_w = rect.w - 54.4 - 32.; + let label_h = + (assets.lines(label, font, label_w) as f32 * font.size * font.line_height) + .min(rect.h - 32.); + out.icon( + assets, + icon, + Rect::new(cx - w / 2., cy - 20.8, 41.6, 41.6), + WHITE, + ); + out.text( + label, + Rect::new(cx - w / 2. + 54.4, cy - label_h / 2., label_w, label_h), + font, + WHITE, + false, + ); + } + } +} + +fn home(out: &mut Scene, state: &State, assets: &Assets, now: OffsetDateTime) { + let language = state.preferences.language; + let s = state.scroll; + let summary = state + .preferences + .show_weather + .then(|| weather_summary_layout(state, assets)); + let shift = summary + .as_ref() + .map_or(0., |summary| (summary.height + 18.4) / 2.); + let date = language.date(now); + out.text( + date, + Rect::new(84., 227.2 - shift - s, 1200., 17.), + text(14.4, 600, 0.12), + c(0xf5f5f5, 0.72), + false, + ); + let greeting = language.greeting(now.hour(), &state.preferences.greeting_name); + out.text( + &greeting, + Rect::new(84., 265. - shift - s, 1500., 95.), + text(90.4, 500, -0.065).leading(1.05), + WHITE, + false, + ); + if let Some(summary) = summary { + weather_summary(out, state, assets, summary, 265. - shift + 94.92 + 18.4 - s); + } + out.text( + language.t("favorites"), + Rect::new(84., 478.5 - s, 1000., 31.), + text(23.2, 550, -0.035).leading(1.3), + WHITE, + false, + ); + if state.moving.is_some() { + out.text( + language.t("moveApps"), + Rect::new(1686., 485. - s, 150., 24.), + text(14.4, 400, 0.), + c(0xdedede, 1.), + true, + ); + } + let apps = state.apps(); + let names = state.preferences.show_home_names; + let mut grid_height = FACE_HEIGHT; + if state.loading && state.inventory.apps.is_empty() { + for index in 0..6 { + out.shape( + Rect::new( + 84. + index as f32 * GRID_PITCH, + 544.8 - s, + 270., + FACE_HEIGHT, + ), + 19.2, + c(0xffffff, 0.06), + ); + } + } else if apps.is_empty() { + let error = state.error.is_some() && state.inventory.apps.is_empty(); + empty( + out, + state, + assets, + EmptyState { + rect: Rect::new(84., 533.6 - s, 1752., 258.7), + icon: if error { "alert" } else { "heart" }, + title: if error { + language.t("aLittleHelp") + } else { + language.t("makeFavorites") + }, + hint: if error { + state.error.as_deref().unwrap_or("") + } else { + language.t("pinHint") + }, + action: Some(if error { Target::Retry } else { Target::Browse }), + }, + ); + grid_height = 247.5; + } + if !apps.is_empty() { + grid_height = FACE_HEIGHT + + if names { + 18.4 + apps + .iter() + .map(|app| caption_height(state, assets, app, HOME_TILE_WIDTH)) + .fold(49.28, f32::max) + } else { + 0. + }; + } + for (index, app) in apps.iter().enumerate() { + tile( + out, + state, + assets, + app, + Rect::new( + 84. + index as f32 * HOME_TILE_PITCH - state.home_scroll, + 544.8 - s, + HOME_TILE_WIDTH, + FACE_HEIGHT, + ), + names, + false, + ); + } + let input_y = 544.8 + grid_height + 41.6 - s; + let inputs: Vec<_> = state + .inventory + .inputs + .iter() + .filter(|input| state.preferences.input_visible(input)) + .collect(); + if !inputs.is_empty() { + out.text( + language.t("inputsHeading"), + Rect::new(84., input_y + 3.3, 1000., 31.), + text(23.2, 550, -0.035).leading(1.3), + WHITE, + false, + ); + } + for (index, input) in inputs.iter().enumerate() { + let rect = Rect::new( + 84. + (index % 3) as f32 * 591.45, + input_y + 58.4 + (index / 3) as f32 * 126.4, + 569.1, + 104., + ); + let focused = state.focus == Target::Input(input.id.clone()); + out.shadow( + rect, + 18.4, + if focused { 34. } else { 4. }, + if focused { 14. } else { 1. }, + if focused { 0.4 } else { 0.01 }, + ); + out.draw.push(Draw::RoundedGradient( + rect, + 18.4, + if focused { + c(0x30363c, 0.70) + } else { + c(0x20252b, 0.60) + }, + if focused { + c(0x242a30, 0.65) + } else { + c(0x171c22, 0.55) + }, + )); + out.border( + rect, + 18.4, + 1., + c(0xffffff, if focused { 0.24 } else { 0.063 }), + ); + let art = Rect::new(rect.x + 21., rect.y + 24., 56., 56.); + out.draw.push(Draw::RoundedGradient( + art, + 12.8, + c(0xd6d6d6, 0.24), + c(0xa0a0a0, 0.10), + )); + let name = state.preferences.input_name(input); + let icon = input_icon(input, name); + let size = if icon == "apple-tv" { 41.6 } else { 35.2 }; + out.icon( + assets, + &format!("input-{icon}"), + Rect::new( + art.x + (56. - size) / 2., + art.y + (56. - size) / 2., + size, + size, + ), + c(0xdedede, 1.), + ); + out.text( + state.preferences.input_title(input), + Rect::new(rect.x + 93., rect.y + 24.3, 450., 28.8), + text(24., 500, -0.03), + WHITE, + false, + ); + out.text( + input_status(input, name.is_some(), language), + Rect::new(rect.x + 93., rect.y + 58.7, 450., 20.), + text(13.12, 400, 0.).leading(1.5), + c(0xe8e8e8, 0.48), + false, + ); + if focused { + out.ring(rect, 18.4); + } + out.control(Target::Input(input.id.clone()), rect, true); + } +} + +struct WeatherSummary { + height: f32, + width: f32, + icon: String, + parts: Vec<(String, TextStyle, Color, Rect)>, +} +type WeatherPart = (String, TextStyle, Color, f32, f32); +fn weather_summary_layout(state: &State, assets: &Assets) -> WeatherSummary { + let language = state.preferences.language; + let base = text(23.2, 400, -0.025).leading(1.5); + let mut parts = Vec::new(); + let mut icon = "location".to_owned(); + if let Some(place) = &state.preferences.weather_location { + parts.push((place.name.clone(), base.weight(450), c(0xf5f5f5, 0.72))); + parts.push(("·".into(), base, c(0xf5f5f5, 0.324))); + if let Some(reading) = state.weather.reading(place) { + let condition = weather::condition(reading); + icon = format!("weather-{}", condition.icon); + parts.push(( + weather::format_temperature( + reading.temperature, + state.preferences.temperature_unit, + ), + base.weight(550), + c(0xf5f5f5, 0.91), + )); + parts.push((language.t(condition.key).into(), base, c(0xf5f5f5, 0.58))); + if state.weather.failed { + parts.push(( + language.t("lastReading").into(), + text(15.08, 400, 0.).leading(1.5), + c(0xf2d5a5, 0.65), + )); + } + } else { + parts.push(( + if state.weather.loading { + language.t("loadingWeather") + } else { + language.t("weatherUnavailable") + } + .into(), + base, + c(0xf5f5f5, 0.58), + )); + } + } else { + parts.push(( + language.t("setWeatherLocation").into(), + base, + c(0xf5f5f5, 0.72), + )); + } + let mut rows: Vec> = vec![Vec::new()]; + let mut used = 0.; + for (label, style, color) in parts { + let measured_width = assets.width(&label, style); + let width = measured_width.min(1714.4); + // Use the same extra pixel as the rendered text rectangle. Counting + // lines at the exact advance width can round into a phantom second + // line even though the renderer displays the label on one line. + let text_width = width + 1.; + let lines = if measured_width <= text_width && !label.contains('\n') { + 1 + } else { + assets.lines(&label, style, text_width) + }; + let height = lines as f32 * style.size * style.line_height; + if used > 0. && used + 11.2 + width > 1714.4 { + rows.push(Vec::new()); + used = 0.; + } + rows.last_mut() + .unwrap() + .push((label, style, color, width, height)); + used += width + if used > 0. { 11.2 } else { 0. }; + } + let mut positioned = Vec::new(); + let mut top = 1.6; + let mut right: f32 = 0.; + for row in rows { + let height = row.iter().map(|part| part.4).fold(34.8, f32::max); + let mut x = 121.6; + for (label, style, color, width, item_height) in row { + positioned.push(( + label, + style, + color, + Rect::new( + x, + top + (height - item_height) / 2., + width + 1., + item_height, + ), + )); + x += width + 11.2; + } + right = right.max(x - 11.2); + top += height + 11.2; + } + WeatherSummary { + height: top - 11.2 + 1.6, + width: right - 84., + icon, + parts: positioned, + } +} +fn weather_summary( + out: &mut Scene, + state: &State, + assets: &Assets, + summary: WeatherSummary, + y: f32, +) { + let label_focus = summary.parts.first().map(|(_, style, _, rect)| { + // The label's layout line box includes leading above and below the + // text. Keep the focus ring snug to the label rather than the whole + // weather row, which also contains the location icon. + let leading = style.size * (style.line_height - 1.).max(0.); + Rect::new( + rect.x, + y + rect.y + leading / 2., + rect.w, + (rect.h - leading).max(style.size), + ) + }); + out.icon( + assets, + &summary.icon, + Rect::new(84., y + 5.8, 26.4, 26.4), + c(0xdedede, 0.83), + ); + for (label, style, color, rect) in summary.parts { + out.text( + label, + Rect::new(rect.x, rect.y + y, rect.w, rect.h), + style, + color, + false, + ); + } + if state.preferences.weather_location.is_none() { + let rect = Rect::new(84., y, summary.width, summary.height); + if state.focus == Target::WeatherConfigure + && !state.pointer + && let Some(label) = label_focus + { + out.border(label.inset(-5.), 8.6, 2., c(0xf0f0f0, 0.95)); + } + out.control(Target::WeatherConfigure, rect, true); + } +} + +fn library(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let s = state.scroll; + let mut x = 84.; + for (index, label) in CATEGORIES.into_iter().enumerate() { + let label = language.category(label); + let width = assets.width(label, text(16., 400, 0.)) + 35.6; + let rect = Rect::new(x, 171.4 - s, width, 42.8); + filter( + out, + state, + Target::Filter(index + 1), + rect, + label, + state.filter == index + 1, + ); + x += width + 7.2; + } + if state.hidden_count() > 0 { + let label = language.format("hidden", &[("count", &state.hidden_count().to_string())]); + let rect = Rect::new( + x, + 171.4 - s, + assets.width(&label, text(16., 400, 0.)) + 35.6, + 42.8, + ); + filter( + out, + state, + Target::Filter(5), + rect, + &label, + state.filter == 5, + ); + x += rect.w + 7.2; + } + if state.filter != 0 { + let rect = Rect::new(x, 176.8 - s, 32., 32.); + out.shape(rect, 16., c(0x23282c, 0.5)); + out.icon(assets, "close", rect.inset(8.), c(0xeeeeee, 0.73)); + if state.focus == Target::ClearFilter { + out.border(rect.inset(-2.), 18., 2., WHITE); + } + out.control(Target::ClearFilter, rect, true); + } + let search = Rect::new(1532., 164.8 - s, 304., 56.); + let focused = matches!(state.focus, Target::Search | Target::ClearSearch); + out.shape( + search, + 48., + if focused { + c(0x303438, 1.) + } else { + c(0x171c20, 0.72) + }, + ); + out.border( + search, + 48., + 1., + c(0xffffff, if focused { 0.70 } else { 0.07 }), + ); + if focused { + out.border(search.inset(-3.), 51., 3., c(0xffffff, 0.063)); + } + out.icon( + assets, + "search", + Rect::new(search.x + 19.4, search.y + 18.4, 19.2, 19.2), + c(0xeeeeee, 0.73), + ); + out.text( + if state.query.is_empty() { + language.t("findApp") + } else { + &state.query + }, + Rect::new( + search.x + 49.8, + search.y + 17.8, + if state.query.is_empty() { 234. } else { 202. }, + 22., + ), + text(16.8, 400, -0.02), + c(0xeeeeee, if state.query.is_empty() { 0.73 } else { 1. }), + false, + ); + out.control(Target::Search, search, true); + if !state.query.is_empty() { + let clear = Rect::new(search.x + search.w - 45.8, search.y + 14.8, 26.4, 26.4); + out.shape(clear, 13.2, c(0xffffff, 0.055)); + out.icon(assets, "close", clear.inset(5.2), WHITE); + if state.focus == Target::ClearSearch { + out.border(clear.inset(-2.), 15.2, 2., WHITE); + } + out.control(Target::ClearSearch, clear, true); + } + let favorites = state.preferences.favorites(&state.inventory.apps); + let apps = state.apps(); + if apps.is_empty() { + let title = if state.loading { + language.t("findingApps") + } else if !state.query.is_empty() { + language.t("noMatches") + } else if state.filter == 5 { + language.t("noHidden") + } else { + language.t("roomForNew") + }; + let hint = state.error.clone().unwrap_or_else(|| { + if !state.query.is_empty() { + language.format("noMatchesHint", &[("query", &state.query)]) + } else if state.filter == 5 { + language.t("hiddenHint").into() + } else { + language.t("noCategoryApps").into() + } + }); + empty( + out, + state, + assets, + EmptyState { + rect: Rect::new(84., 259.2 - s, 1752., 304.), + icon: "grid", + title, + hint: &hint, + action: state.error.as_ref().map(|_| Target::Retry), + }, + ); + } + let mut y = 270.4; + let names = state.preferences.show_app_names; + for row in apps.chunks(APPS_COLUMNS) { + let row_height = FACE_HEIGHT + + if names { + 18.4 + row + .iter() + .map(|app| caption_height(state, assets, app, 270.)) + .fold(49.28, f32::max) + } else { + 0. + }; + for (column, app) in row.iter().enumerate() { + tile( + out, + state, + assets, + app, + Rect::new(84. + column as f32 * GRID_PITCH, y - s, 270., FACE_HEIGHT), + names, + favorites.contains(&app.id), + ); + } + y += row_height + 38.4; + } +} + +fn filter(out: &mut Scene, state: &State, target: Target, rect: Rect, label: &str, selected: bool) { + if selected { + out.shape(rect, 32., c(0xffffff, 0.11)); + out.border(rect, 32., 1., c(0xffffff, 0.07)); + } + if state.focus == target && !state.pointer { + out.border(rect.inset(-7.), 39., 2., c(0xf0f0f0, 0.95)); + } + out.text( + label, + Rect::new(rect.x, rect.y + 11.4, rect.w, 20.), + text(16., 400, 0.), + c( + if selected { 0xf5f5f5 } else { 0xf0f0f0 }, + if selected { 1. } else { 0.66 }, + ), + true, + ); + out.control(target, rect, true); +} + +struct Row<'a> { + target: Target, + rect: Rect, + title: &'a str, + icon: Option<&'a str>, + value: Option<&'a str>, + description: Option<&'a str>, + selected: bool, + chevron: bool, +} +fn row(out: &mut Scene, state: &State, assets: &Assets, row: Row<'_>) { + let focused = state.focus == row.target; + let bright = focused; + let radius = if row.description.is_some() { 20. } else { 48. }; + if bright { + out.draw.push(Draw::RoundedGradient( + row.rect, + radius, + c(0xfafafa, 1.), + c(0xe5e5e5, 1.), + )); + } else { + out.shape(row.rect, radius, c(0x171c20, 0.72)); + out.border(row.rect, radius, 1., c(0xffffff, 0.07)); + } + let color = if bright { + c(0x2c2c2c, 1.) + } else { + c(0xeeeeee, 0.73) + }; + let mut x = row.rect.x + 24.2; + if let Some(icon) = row.icon { + out.icon( + assets, + icon, + Rect::new(x, row.rect.y + (row.rect.h - 23.2) / 2., 23.2, 23.2), + if bright { + c(0x444444, 1.) + } else { + c(0xe4e4e4, 0.61) + }, + ); + x += 39.2; + } + let title_y = row.rect.y + + if row.description.is_some() { + 17. + } else { + (row.rect.h - 29.12) / 2. + }; + let title_width = row.rect.w - (x - row.rect.x) - if row.value.is_some() { 210. } else { 64. }; + let title_height = if row.description.is_some() { + assets.lines(row.title, text(20.8, 550, -0.02), title_width) as f32 * 29.12 + } else { + 29.12 + }; + out.text( + row.title, + Rect::new(x, title_y, title_width, title_height), + text(20.8, 550, -0.02).leading(1.4), + color, + false, + ); + if let Some(description) = row.description { + out.text( + description, + Rect::new( + x, + title_y + title_height + 4.8, + row.rect.w - 100., + (row.rect.h - 34. - title_height - 4.8).max(22.8), + ), + text(15.2, 400, 0.).leading(1.5), + if bright { + c(0x555555, 1.) + } else { + c(0xf5f5f5, 0.56) + }, + false, + ); + } + if let Some(value) = row.value { + out.text( + value, + Rect::new( + row.rect.x + row.rect.w - 194.2 - if row.chevron { 34.4 } else { 0. }, + row.rect.y + (row.rect.h - 23.) / 2., + 170., + 24., + ), + text(18.4, 450, 0.).right(), + if bright { + c(0x444444, 1.) + } else { + c(0xeeeeee, 0.73) + }, + false, + ); + } + if row.chevron || row.selected { + out.icon( + assets, + if row.selected { "check" } else { "right" }, + Rect::new( + row.rect.x + row.rect.w - 42.6, + row.rect.y + (row.rect.h - 18.4) / 2., + 18.4, + 18.4, + ), + if bright { + c(0x444444, 1.) + } else { + c(0xe4e4e4, 0.61) + }, + ); + } + out.control(row.target, row.rect, true); +} + +fn weather_settings(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let s = state.scroll; + row( + out, + state, + assets, + Row { + target: Target::WeatherToggle, + rect: Rect::new(640., 164.8 - s, 1120., 72.), + title: language.t("showWidget"), + icon: Some("eye"), + value: Some(if state.preferences.show_weather { + language.t("stateOn") + } else { + language.t("stateOff") + }), + description: None, + selected: false, + chevron: false, + }, + ); + let city = state + .preferences + .weather_location + .as_ref() + .map(|place| place.name.as_str()); + let style = text(18.4, 450, 0.).leading(1.4); + let city_lines = city.map_or(1, |city| assets.lines(city, style, 450.)); + let height = (34. + city_lines as f32 * 25.76).max(72.); + let rect = Rect::new(640., 264.8 - s, 1120., height); + row( + out, + state, + assets, + Row { + target: Target::WeatherLocation, + rect, + title: language.t("location"), + icon: Some("location"), + value: None, + description: None, + selected: false, + chevron: true, + }, + ); + if let Some(city) = city { + out.text( + city, + Rect::new( + rect.x + rect.w - 508.6, + rect.y + (height - city_lines as f32 * 25.76) / 2., + 450., + city_lines as f32 * 25.76, + ), + style.right(), + if state.focus == Target::WeatherLocation { + c(0x444444, 1.) + } else { + c(0xeeeeee, 0.73) + }, + false, + ); + } + let y = rect.y + height + 12.; + row( + out, + state, + assets, + Row { + target: Target::WeatherTemperature, + rect: Rect::new(640., y, 1120., 72.), + title: language.t("temperature"), + icon: Some("thermometer"), + value: Some(state.preferences.temperature_unit.symbol()), + description: None, + selected: false, + chevron: true, + }, + ); +} + +fn location_settings(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let mut y = 164.8 - state.scroll; + if let Some(place) = &state.preferences.weather_location { + let rect = Rect::new(640., y, 1120., 76.2); + out.shape(rect, 20., c(0x171c20, 0.72)); + out.border(rect, 20., 1., c(0xffffff, 0.04)); + out.icon( + assets, + "location", + Rect::new(664.2, y + 29.1, 18., 18.), + c(0xcccccc, 1.), + ); + out.text( + &place.name, + Rect::new(698.2, y + 17., 920., 25.), + text(18.4, 500, 0.), + WHITE, + false, + ); + out.text( + place.detail(), + Rect::new(698.2, y + 42.3, 920., 22.), + text(14.4, 400, 0.), + c(0xf5f5f5, 0.56), + false, + ); + let clear = Rect::new(1695.8, y + 18.1, 40., 40.); + out.shape(clear, 20., c(0x23282c, 0.5)); + out.border(clear, 20., 1., c(0xffffff, 0.07)); + out.icon(assets, "close", clear.inset(12.), WHITE); + if state.focus == Target::WeatherClear { + out.border(clear.inset(-2.), 22., 2., c(0xf0f0f0, 0.95)); + } + out.control(Target::WeatherClear, clear, true); + y += 96.2; + } + let query = Rect::new(640., y, 1044., 64.); + let focused = state.focus == Target::WeatherQuery; + out.shape( + query, + 48., + if focused { + c(0x303438, 1.) + } else { + c(0x171c20, 0.72) + }, + ); + out.border( + query, + 48., + 1., + c(0xffffff, if focused { 0.70 } else { 0.07 }), + ); + if focused { + out.border(query.inset(-3.), 51., 3., c(0xffffff, 0.063)); + } + out.icon( + assets, + "search", + Rect::new(664.2, y + 21.2, 21.6, 21.6), + c(0xeeeeee, 0.73), + ); + out.text( + if state.weather.query.is_empty() { + language.t("cityPostcode") + } else { + &state.weather.query + }, + Rect::new(699.4, y + 20.9, 950., 24.), + text(18.4, 400, -0.02), + c(0xeeeeee, 0.73), + false, + ); + out.control(Target::WeatherQuery, query, true); + let search = Rect::new(1696., y, 64., 64.); + out.shape(search, 32., c(0x171c20, 0.72)); + out.border(search, 32., 1., c(0xffffff, 0.07)); + out.icon( + assets, + if state.weather.searching { + "refresh" + } else { + "search" + }, + search.inset(20.8), + WHITE, + ); + if state.focus == Target::WeatherSearch { + out.border(search.inset(-7.), 39., 2., c(0xf0f0f0, 0.95)); + } + out.control(Target::WeatherSearch, search, true); + y += 84.; + if state.weather.searching || state.weather.message.is_some() { + out.text( + if state.weather.searching { + language.t("searching") + } else { + language.t(state.weather.message.as_deref().unwrap_or("")) + }, + Rect::new(664.2, y, 1071.6, 58.), + text(16., 400, 0.).leading(1.5), + c(0xe0e0e0, 0.68), + false, + ); + } + for (index, place) in state.weather.results.iter().enumerate() { + let detail = place.detail(); + let height = (34. + + assets.lines(&place.name, text(20.8, 550, -0.02), 992.6) as f32 * 29.12 + + 4.8 + + assets.lines(&detail, text(15.2, 400, 0.), 1020.) as f32 * 22.8) + .max(90.7); + let rect = Rect::new(640., y, 1120., height); + row( + out, + state, + assets, + Row { + target: Target::WeatherResult(index), + rect, + title: &place.name, + icon: Some("location"), + value: None, + description: Some(&detail), + selected: false, + chevron: false, + }, + ); + if state + .preferences + .weather_location + .as_ref() + .is_some_and(|saved| saved.key() == place.key()) + { + out.icon( + assets, + "check", + Rect::new(1717.4, y + (height - 18.4) / 2., 18.4, 18.4), + if state.focus == Target::WeatherResult(index) { + c(0x444444, 1.) + } else { + c(0xe4e4e4, 0.61) + }, + ); + } + y += height + 12.; + } +} + +fn ha(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let rect = Rect::new(84., 164.8, 1752., 780.); + empty( + out, + state, + assets, + EmptyState { + rect, + icon: "settings", + title: language.t("ha"), + hint: "Home Assistant", + action: None, + }, + ); +} + +fn settings(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let s = state.scroll; + for (index, key) in ["interface", "background", "weather", "inputs", "system"] + .into_iter() + .enumerate() + { + let label = language.t(key); + let rect = Rect::new(160., 164.8 + index as f32 * 84. - s, 403.2, 72.); + let focused = state.focus == Target::Sidebar(index); + out.pill(rect, focused, state.section == index); + let color = if focused { + c(0x2c2c2c, 1.) + } else if state.section == index { + WHITE + } else { + c(0xeeeeee, 0.73) + }; + if state.section == index && state.subpage.is_some() { + let clock_path = match state.subpage { + Some(Subpage::ClockFormat) => Some(format!( + "{} › {}", + language.t("clock"), + language.t("clockFormat") + )), + Some(Subpage::TimeFormat) => Some(format!( + "{} › {}", + language.t("clock"), + language.t("timeFormat") + )), + _ => None, + }; + let sub = match &state.subpage { + Some(Subpage::AppNames) => language.t("appNames"), + Some(Subpage::Greeting) => language.t("greeting"), + Some(Subpage::Clock) => language.t("clock"), + Some(Subpage::ClockFormat | Subpage::TimeFormat) => clock_path.as_deref().unwrap(), + Some(Subpage::Input(id)) => state + .inventory + .inputs + .iter() + .find(|input| &input.id == id) + .map_or(language.t("inputs"), |input| { + state.preferences.input_title(input) + }), + Some(Subpage::WeatherLocation) => language.t("location"), + Some(Subpage::Temperature) => language.t("temperature"), + Some(Subpage::Language) => language.t("language"), + None => "", + }; + let parent_width = assets.width(label, text(20.8, 550, -0.02)) + 2.; + out.text( + label, + Rect::new(rect.x + 24.2, rect.y + 23.5, parent_width, 25.), + text(20.8, 550, -0.02), + [color[0], color[1], color[2], color[3] * 0.7], + false, + ); + out.icon( + assets, + "right", + Rect::new(rect.x + 24.2 + parent_width + 5., rect.y + 27., 17., 17.), + [color[0], color[1], color[2], color[3] * 0.6], + ); + let sub_width = rect.w - 24.2 - parent_width - 28. - 24.2; + let mut sub_style = text(20.8, 550, -0.02); + if clock_path.is_some() { + sub_style.size *= (sub_width / assets.width(sub, sub_style)).min(1.); + } + out.text( + sub, + Rect::new( + rect.x + 24.2 + parent_width + 28., + rect.y + 23.5, + sub_width, + 25., + ), + sub_style, + color, + false, + ); + } else { + out.text( + label, + Rect::new(rect.x + 24.2, rect.y + 23.5, 330., 25.), + text(20.8, 550, -0.02), + color, + false, + ); + if state.section == index { + out.icon( + assets, + "right", + Rect::new(rect.x + rect.w - 42.6, rect.y + 26.8, 18.4, 18.4), + if focused { + c(0x2c2c2c, 0.6) + } else { + c(0xeeeeee, 0.5) + }, + ); + } + } + out.control(Target::Sidebar(index), rect, true); + } + if let Some(subpage) = &state.subpage { + let start = out.draw.len(); + if matches!( + subpage, + Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock + ) { + match subpage { + Subpage::WeatherLocation => location_settings(out, state, assets), + Subpage::Greeting => greeting_settings(out, state, assets), + Subpage::AppNames => app_name_settings(out, state, assets), + Subpage::Clock => clock_settings(out, state, assets), + _ => unreachable!(), + } + if let Some(opened) = state.panel_started { + out.fade(start, (opened.elapsed().as_secs_f32() / 0.15).min(1.), 1.); + } + return; + } + let options: Vec<(&str, Option<&str>, bool)> = match subpage { + Subpage::ClockFormat => vec![ + (language.t("hour12"), None, !state.preferences.hour_24), + (language.t("hour24"), None, state.preferences.hour_24), + ], + Subpage::TimeFormat => vec![ + ("HH:MM", None, !state.preferences.show_seconds), + ("HH:MM:SS", None, state.preferences.show_seconds), + ], + Subpage::Input(id) => { + let current = state + .preferences + .input_modes + .get(id) + .copied() + .unwrap_or_default(); + vec![ + ( + language.t("stateOn"), + Some(language.t("inputAlways")), + current == InputMode::On, + ), + ( + language.t("stateOff"), + Some(language.t("inputNever")), + current == InputMode::Off, + ), + ( + language.t("stateAuto"), + Some(language.t("inputWhenConnected")), + current == InputMode::Auto, + ), + ] + } + Subpage::Temperature => vec![ + ( + language.t("celsius"), + None, + state.preferences.temperature_unit == Unit::Celsius, + ), + ( + language.t("fahrenheit"), + None, + state.preferences.temperature_unit == Unit::Fahrenheit, + ), + ], + Subpage::WeatherLocation | Subpage::Greeting | Subpage::AppNames | Subpage::Clock => { + unreachable!() + } + Subpage::Language => Language::ALL + .into_iter() + .map(|option| (option.name(), None, option == language)) + .collect(), + }; + for (index, (title, description, selected)) in options.into_iter().enumerate() { + let h = if description.is_some() { 90.7 } else { 72. }; + row( + out, + state, + assets, + Row { + target: Target::Choice(index), + rect: Rect::new(640., 164.8 + index as f32 * (h + 12.) - s, 1120., h), + title, + icon: None, + value: None, + description, + selected, + chevron: false, + }, + ); + } + if let Subpage::Input(id) = subpage { + input_name_rows(out, state, assets, id, 164.8 + 3. * 102.7 + 16. - s); + } + if let Some(opened) = state.panel_started { + out.fade(start, (opened.elapsed().as_secs_f32() / 0.15).min(1.), 1.); + } + return; + } + match state.section { + 0 => { + for (position, (index, key, icon, value, chevron)) in [ + (0, "appNames", "type", None, true), + (1, "clock", "clock", None, true), + ( + 2, + "reducedMotion", + "motion", + Some(if state.preferences.reduced_motion { + language.t("stateOn") + } else { + language.t("stateOff") + }), + false, + ), + (4, "language", "language", Some(language.name()), true), + ( + 6, + "greeting", + "type", + Some(if state.preferences.greeting_name.trim().is_empty() { + language.t("stateOff") + } else { + state.preferences.greeting_name.as_str() + }), + true, + ), + ] + .into_iter() + .enumerate() + { + row( + out, + state, + assets, + Row { + target: Target::Setting(index), + rect: Rect::new(640., 164.8 + position as f32 * 84. - s, 1120., 72.), + title: language.t(key), + icon: Some(icon), + value, + description: None, + selected: false, + chevron, + }, + ); + } + } + 1 => { + for (index, (id, _title)) in BACKGROUNDS.iter().enumerate() { + let rect = Rect::new( + 640. + (index % 3) as f32 * 380., + 164.8 + (index / 3) as f32 * 264.3 - s, + 360., + 202.5, + ); + out.shadow(rect, 16., 15., 3., 0.13); + out.image( + assets.path(format!("thumbnails/{id}.jpg")), + rect, + 16., + false, + ); + out.border(rect, 16., 1., c(0xffffff, 0.086)); + if state.preferences.background == *id { + out.border(rect.inset(-2.), 18., 2., c(0xdedede, 1.)); + out.border(rect.inset(-7.), 23., 3., c(0xdedede, 0.10)); + let check = Rect::new(rect.x + rect.w - 28., rect.y + rect.h - 28., 21.6, 21.6); + out.shape(check, 10.8, c(0xf3f3f3, 1.)); + out.icon(assets, "check", check.inset(3.6), c(0x333333, 1.)); + } + if state.focus == Target::Background(index) { + out.ring(rect, 16.); + } + out.text( + language.wallpaper(id), + Rect::new(rect.x + 4., rect.y + rect.h + 12., 352., 26.), + text(18.4, 500, -0.02).leading(1.4), + c(0xf0f0f0, 0.87), + false, + ); + out.control( + Target::Background(index), + Rect::new(rect.x, rect.y, rect.w, rect.h + 37.8), + true, + ); + } + } + WEATHER_SECTION => weather_settings(out, state, assets), + SYSTEM_SECTION => { + for (position, (index, title, icon)) in [ + ( + 3, + language.t(if state.loading { + "refreshNotice" + } else { + "refreshApps" + }), + "refresh", + ), + (5, language.t("quitApp"), "power"), + ] + .into_iter() + .enumerate() + { + row( + out, + state, + assets, + Row { + target: Target::Setting(index), + rect: Rect::new(640., 164.8 + position as f32 * 84. - s, 1120., 72.), + title, + icon: Some(icon), + value: None, + description: None, + selected: false, + chevron: false, + }, + ); + } + } + _ => { + for (index, input) in state.inventory.inputs.iter().enumerate() { + let mode = state + .preferences + .input_modes + .get(&input.id) + .copied() + .unwrap_or_default(); + row( + out, + state, + assets, + Row { + target: Target::InputMode(input.id.clone()), + rect: Rect::new(640., 164.8 + index as f32 * 102.7 - s, 1120., 90.7), + title: state.preferences.input_title(input), + icon: Some(&format!( + "input-{}", + input_icon(input, state.preferences.input_name(input)) + )), + value: Some(language.t(match mode { + InputMode::On => "stateOn", + InputMode::Off => "stateOff", + InputMode::Auto => "stateAuto", + })), + description: Some(&input_status( + input, + state.preferences.input_name(input).is_some(), + language, + )), + selected: false, + chevron: true, + }, + ); + } + let mut y = 164.8 + state.inventory.inputs.len() as f32 * 102.7 + 16. - s; + if state.inventory.inputs.is_empty() { + out.text( + language.t("noInputs"), + Rect::new(664., 165. - s, 1000., 26.), + text(16.8, 400, 0.).leading(1.5), + c(0xf5f5f5, 0.72), + false, + ); + y += 36.; + } + row( + out, + state, + assets, + Row { + target: Target::Setting(3), + rect: Rect::new(640., y, 1120., 72.), + title: if state.loading { + language.t("refreshNotice") + } else { + language.t("refreshInputs") + }, + icon: Some("refresh"), + value: None, + description: None, + selected: false, + chevron: false, + }, + ); + } + } +} + +fn greeting_settings(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let description = if state.text_input_active() && state.keyboard_field == TextField::Name { + format!("{}|", state.greeting_name_draft) + } else if state.preferences.greeting_name.is_empty() { + language.t("stateOff").into() + } else { + state.preferences.greeting_name.clone() + }; + row( + out, + state, + assets, + Row { + target: Target::GreetingName, + rect: Rect::new(640., 164.8 - state.scroll, 1120., 90.7), + title: language.t("name"), + icon: Some("type"), + value: None, + description: Some(&description), + selected: false, + chevron: true, + }, + ); + // Always present so completing name entry cannot move a clicked control. + row( + out, + state, + assets, + Row { + target: Target::GreetingNameClear, + rect: Rect::new(640., 267.5 - state.scroll, 1120., 72.), + title: language.t("clearName"), + icon: Some("refresh"), + value: None, + description: None, + selected: false, + chevron: false, + }, + ); +} + +fn clock_settings(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + for (index, (target, key, value)) in [ + ( + Target::ClockFormat, + "clockFormat", + language.t(if state.preferences.hour_24 { + "hour24" + } else { + "hour12" + }), + ), + ( + Target::ClockTimeFormat, + "timeFormat", + if state.preferences.show_seconds { + "HH:MM:SS" + } else { + "HH:MM" + }, + ), + ] + .into_iter() + .enumerate() + { + row( + out, + state, + assets, + Row { + target, + rect: Rect::new(640., 164.8 + index as f32 * 84. - state.scroll, 1120., 72.), + title: language.t(key), + icon: None, + value: Some(value), + description: None, + selected: false, + chevron: true, + }, + ); + } +} + +fn app_name_settings(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + for (index, (target, title, enabled)) in [ + ( + Target::AppNamesHome, + "showOnHome", + state.preferences.show_home_names, + ), + ( + Target::AppNamesApps, + "showOnApps", + state.preferences.show_app_names, + ), + ] + .into_iter() + .enumerate() + { + row( + out, + state, + assets, + Row { + target, + rect: Rect::new(640., 164.8 + index as f32 * 84. - state.scroll, 1120., 72.), + title: language.t(title), + icon: None, + value: Some(language.t(if enabled { "stateOn" } else { "stateOff" })), + description: None, + selected: false, + chevron: false, + }, + ); + } +} + +fn input_name_rows(out: &mut Scene, state: &State, assets: &Assets, id: &str, y: f32) { + let language = state.preferences.language; + let custom = state.preferences.input_names.get(id); + let editing = state.text_input_active() && state.keyboard_field == TextField::InputName; + let description = if editing { + format!("{}|", state.input_name) + } else { + custom + .cloned() + .unwrap_or_else(|| language.t("inputNameHint").into()) + }; + row( + out, + state, + assets, + Row { + target: Target::InputRename, + rect: Rect::new(640., y, 1120., 90.7), + title: language.t("renameInput"), + icon: Some("type"), + value: None, + description: Some(&description), + selected: false, + chevron: false, + }, + ); + if custom.is_some() { + row( + out, + state, + assets, + Row { + target: Target::InputNameReset, + rect: Rect::new(640., y + 102.7, 1120., 72.), + title: language.t("clearName"), + icon: Some("refresh"), + value: None, + description: None, + selected: false, + chevron: false, + }, + ); + } +} + +/// A custom name such as "PS5" can choose the icon; otherwise the TV's label. +fn input_icon(input: &Input, custom: Option<&str>) -> &'static str { + custom + .map(device_icon) + .filter(|icon| *icon != "hdmi") + .unwrap_or_else(|| device_icon(&input.title)) +} + +fn device_icon(name: &str) -> &'static str { + let name = name.to_lowercase().replace(' ', ""); + if name.contains("playstation") || ["ps1", "ps2", "ps3", "ps4", "ps5"].contains(&name.as_str()) + { + "playstation" + } else if name.contains("appletv") { + "apple-tv" + } else if name.contains("usb") { + "usb" + } else if ["xbox", "switch", "console"] + .iter() + .any(|part| name.contains(part)) + { + "console" + } else if ["pc", "computer", "laptop"] + .iter() + .any(|part| name.contains(part)) + { + "computer" + } else if ["stream", "fire", "roku"] + .iter() + .any(|part| name.contains(part)) + { + "streamer" + } else { + "hdmi" + } +} +/// Detail under an input title: the TV's own label (when connected and not just +/// the port), the port when a custom name replaces it, then the status. +pub fn input_status(input: &Input, renamed: bool, language: Language) -> String { + let mut parts = vec![]; + if input.connected == Some(true) && input.title != input.port { + parts.push(input.title.as_str()); + } + if renamed { + parts.push(input.port.as_str()); + } + parts.push(match input.connected { + Some(false) => language.t("noDevice"), + None => language.t("externalInput"), + Some(true) => language.t("connected"), + }); + parts.join(" · ") +} + +fn keycap(out: &mut Scene, label: &str, x: f32, y: f32, w: f32, small: bool) { + let h = if small { 16. } else { 23.2 }; + out.border( + Rect::new(x, y, w, h), + 4.8, + 1., + c(0xffffff, if small { 0.125 } else { 0.20 }), + ); + out.text( + label, + Rect::new(x, y + if small { 2. } else { 4.2 }, w, h), + text(if small { 8. } else { 11.52 }, 500, 0.), + c(0xf3f3f3, 0.64), + true, + ); +} + +fn footer(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let style = text(14.4, 400, 0.005); + if state.page == Page::Home && state.moving.is_none() { + return; + } + let color = c(0xeeeeee, 0.37); + if state.moving.is_some() { + keycap(out, "← →", 84., 1030.4, 42., false); + out.text( + language.t("move"), + Rect::new(134., 1033., 100., 20.), + text(14.4, 400, 0.005), + color, + false, + ); + let second_x = 134. + assets.width(language.t("move"), style) + 25.6; + keycap(out, "OK/Back", second_x, 1030.4, 62., false); + out.text( + language.t("done"), + Rect::new(second_x + 70., 1033., 150., 20.), + text(14.4, 400, 0.005), + color, + false, + ); + } else { + let label = language.t(if state.page == Page::Settings { + "select" + } else { + "open" + }); + let second_x = 119. + assets.width(label, style) + 25.6; + keycap(out, "OK", 84., 1030.4, 27., false); + out.text( + label, + Rect::new(119., 1033., 100., 20.), + text(14.4, 400, 0.005), + color, + false, + ); + if state.page == Page::Settings { + keycap(out, "Back", second_x, 1030.4, 39., false); + out.text( + language.t("homeLabel"), + Rect::new(second_x + 47., 1033., 180., 20.), + text(14.4, 400, 0.005), + color, + false, + ); + out.text( + format!("v{}", assets.version.trim()), + Rect::new(1775., 1037., 61., 16.), + text(12.48, 400, 0.), + c(0xf5f5f5, 0.56), + false, + ); + } else { + out.icon( + assets, + "more", + Rect::new(second_x, 1032., 19.2, 19.2), + c(0xeeeeee, 0.26), + ); + out.text( + language.t("options"), + Rect::new(second_x + 27.2, 1033., 400., 20.), + text(14.4, 400, 0.005), + color, + false, + ); + } + } +} + +struct EmptyState<'a> { + rect: Rect, + icon: &'a str, + title: &'a str, + hint: &'a str, + action: Option, +} +fn empty(out: &mut Scene, state: &State, assets: &Assets, content: EmptyState<'_>) { + let EmptyState { + rect, + icon, + title, + hint, + action, + } = content; + out.shape(rect, 17.6, c(0xffffff, 0.02)); + out.border(rect, 17.6, 1., c(0xffffff, 0.05)); + let center = rect.x + rect.w / 2.; + let top = rect.y + + if action.is_some() { + 33. + } else { + (rect.h - 123.2) / 2. + }; + out.icon( + assets, + icon, + Rect::new(center - 17.6, top, 35.2, 35.2), + c(0xd8d8d8, 0.60), + ); + out.text( + title, + Rect::new(rect.x + 32., top + 52., rect.w - 64., 31.), + text(24., 550, -0.035), + WHITE, + true, + ); + out.text( + hint, + Rect::new(center - 352., top + 97.6, 704., 64.), + text(16., 400, 0.).leading(1.6), + c(0xf5f5f5, 0.72), + true, + ); + if let Some(target) = action { + let label = state.preferences.language.t(if target == Target::Retry { + "retry" + } else { + "exploreApps" + }); + let width = assets.width(label, text(15.2, 600, 0.)) + 72.; + let button = Rect::new(center - width / 2., top + 144.8, width, 46.8); + out.shape(button, 11.2, c(0xededed, 1.)); + if state.focus == target { + out.border(button.inset(-7.), 18.2, 2., WHITE); + } + out.text( + label, + Rect::new(button.x + 20.8, button.y + 14.4, button.w - 72., 18.4), + text(15.2, 600, 0.), + c(0x242424, 1.), + false, + ); + out.icon( + assets, + "arrow", + Rect::new(button.x + button.w - 38.4, button.y + 14.6, 17.6, 17.6), + c(0x242424, 1.), + ); + out.control(target, button, true); + } +} + +fn options(out: &mut Scene, state: &State, assets: &Assets, id: &str) { + let language = state.preferences.language; + let app = state.inventory.apps.iter().find(|app| app.id == id); + let title = app.map_or(id, |app| app.title.as_str()); + let extra = assets + .lines(title, text(37.6, 500, -0.055).leading(1.1), 432.) + .saturating_sub(1) as f32 + * 41.4; + let panel = Rect::new(680., 362.9 - extra / 2., 560., 354.2 + extra); + out.modal = Some(panel); + out.shadow(panel, 27.2, 120., 25., 0.47); + out.draw.push(Draw::RoundedGradient( + panel, + 27.2, + c(0x34383b, 0.96), + c(0x24282d, 0.988), + )); + out.border(panel, 27.2, 1., c(0xffffff, 0.12)); + out.text( + title, + Rect::new(717.8, panel.y + 36.2, 432., 42. + extra), + text(37.6, 500, -0.055).leading(1.1), + WHITE, + false, + ); + out.text( + language.category(app.map_or("Utilities", |app| app.category.as_str())), + Rect::new(717.8, panel.y + 87.9 + extra, 440., 23.), + text(15.2, 400, 0.).leading(1.5), + c(0xf5f5f5, 0.56), + false, + ); + let close = Rect::new(1168.6, panel.y + 39.4, 33.6, 33.6); + out.shape(close, 16.8, c(0xffffff, 0.035)); + out.border(close, 16.8, 1., c(0xffffff, 0.07)); + out.icon(assets, "close", close.inset(7.6), WHITE); + if state.focus == Target::CloseDialog { + out.border(close.inset(1.), 15.8, 2., WHITE); + } + out.control(Target::CloseDialog, close, false); + let favorite = state + .preferences + .favorites(&state.inventory.apps) + .iter() + .any(|item| item == id); + let hidden = state.preferences.hidden.iter().any(|item| item == id); + for (index, label) in [ + if favorite { + language.t("removeFavorite") + } else { + language.t("addFavorite") + }, + language.t(if hidden { "showApp" } else { "hideApp" }), + ] + .into_iter() + .enumerate() + { + let rect = Rect::new( + 717.8, + panel.y + 137.9 + extra + index as f32 * 76.8, + 484.4, + 67.2, + ); + let focused = state.focus == Target::Option(index); + out.shape(rect, 12., c(0xffffff, if focused { 0.07 } else { 0.016 })); + out.border( + rect, + 12., + if focused { 2. } else { 1. }, + c( + if focused { 0xf0f0f0 } else { 0xffffff }, + if focused { 0.95 } else { 0.05 }, + ), + ); + out.icon( + assets, + if index == 0 { + if favorite { "heart-filled" } else { "heart" } + } else if hidden { + "eye" + } else { + "eye-off" + }, + Rect::new(rect.x + 18.6, rect.y + 22.8, 21.6, 21.6), + if focused { WHITE } else { c(0xe4e4e4, 0.61) }, + ); + out.text( + label, + Rect::new(rect.x + 56.2, rect.y + 22., rect.w - 74., 25.), + text(17.6, 500, -0.015), + WHITE, + false, + ); + out.control(Target::Option(index), rect, false); + } + let line_y = panel.y + panel.h - 53.2; + out.shape(Rect::new(717.8, line_y, 484.4, 1.), 0., c(0xffffff, 0.04)); + let shortcut = text(10.4, 400, 0.); + let select_width = assets.width(language.t("selectClose"), shortcut); + let close_width = assets.width(language.t("close"), shortcut); + let start = 960. - (23. + 6.4 + select_width + 15. + 29. + 6.4 + close_width) / 2.; + let back = start + 29.4 + select_width + 15.; + keycap(out, "OK", start, line_y + 15., 23., true); + out.text( + language.t("selectClose"), + Rect::new(start + 29.4, line_y + 18., select_width + 1., 13.), + text(10.4, 400, 0.), + c(0xe0e0e0, 0.35), + false, + ); + keycap(out, "Back", back, line_y + 15., 29., true); + out.text( + language.t("close"), + Rect::new(back + 35.4, line_y + 18., close_width + 1., 13.), + text(10.4, 400, 0.), + c(0xe0e0e0, 0.35), + false, + ); +} + +fn keyboard(out: &mut Scene, state: &State, assets: &Assets) { + let language = state.preferences.language; + let panel = Rect::new(350., 290., 1220., 530.); + out.modal = Some(panel); + out.shadow(panel, 27.2, 90., 20., 0.45); + out.draw.push(Draw::RoundedGradient( + panel, + 27.2, + c(0x34383b, 0.98), + c(0x24282d, 1.), + )); + out.border(panel, 27.2, 1., c(0xffffff, 0.12)); + out.text( + match state.keyboard_field { + TextField::Location => language.t("cityPostcode"), + TextField::Name => language.t("greetingName"), + TextField::InputName => language.t("inputName"), + TextField::Apps => language.t("findApp"), + }, + Rect::new(389., 324., 1000., 44.), + text(37.6, 500, -0.055).leading(1.1), + WHITE, + false, + ); + let close = Rect::new(1497., 328., 33.6, 33.6); + out.icon(assets, "close", close.inset(7.6), WHITE); + out.control(Target::CloseDialog, close, false); + if state.focus == Target::CloseDialog { + out.border(close, 16.8, 2., WHITE); + } + out.shape(Rect::new(389., 390., 1142., 56.), 28., c(0x171c20, 0.72)); + out.icon( + assets, + "search", + Rect::new(409., 408., 20., 20.), + c(0xeeeeee, 0.73), + ); + out.text( + format!("{}|", state.editing_text()), + Rect::new(446., 406., 1058., 25.), + text(20.8, 400, -0.02), + WHITE, + false, + ); + for (index, key) in KEYS.iter().enumerate() { + let label = match *key { + "Space" => language.t("space"), + "Delete" => language.t("delete"), + "Clear" => language.t("clear"), + "Done" => language.t("done"), + _ => key, + }; + let rect = Rect::new( + 389. + (index % 10) as f32 * 115.6, + 474. + (index / 10) as f32 * 74., + 101.6, + 60., + ); + let focused = state.focus == Target::Key(index); + out.pill(rect, focused, false); + let mut style = text(18.4, 550, -0.02); + let width = assets.width(label, style); + if width > rect.w - 12. { + style.size *= (rect.w - 12.) / width; + } + out.text( + label, + Rect::new(rect.x, rect.y + 18., rect.w, 25.), + style, + if focused { c(0x2c2c2c, 1.) } else { WHITE }, + true, + ); + out.control(Target::Key(index), rect, false); + } +} + +fn toast(out: &mut Scene, state: &State, assets: &Assets, notice: &str) { + let start = out.draw.len(); + let style = text(16., 400, 0.).leading(1.5); + let width = (assets.width(notice, style) + 118.).clamp(240., 1856.); + let height = 34. + assets.lines(notice, style, width - 100.) as f32 * 24.; + let rect = Rect::new(960. - width / 2., 1080. - 51.2 - height, width, height); + out.shadow(rect, 16., 40., 8., 0.33); + out.shape(rect, 16., c(0xededed, 0.96)); + out.border(rect, 16., 1., c(0xffffff, 0.5)); + out.icon( + assets, + "sparkles", + Rect::new(rect.x + 20., rect.y + (height - 20.8) / 2., 20.8, 20.8), + c(0x727272, 1.), + ); + out.text( + notice, + Rect::new(rect.x + 53.6, rect.y + 17., width - 100., height - 34.), + text(16., 400, 0.).leading(1.5), + c(0x303030, 1.), + false, + ); + let close = Rect::new(rect.x + width - 33., rect.y + (height - 20.) / 2., 20., 20.); + out.icon(assets, "close", close.inset(1.2), c(0x6b6b6b, 1.)); + if state.focus == Target::DismissNotice { + out.border(close.inset(-2.), 5., 2., c(0x303030, 1.)); + } + if state.overlay.is_none() { + out.control(Target::DismissNotice, close, false); + } + if !state.preferences.reduced_motion + && let Some((_, opened)) = &state.notice + { + out.fade(start, (opened.elapsed().as_secs_f32() / 0.12).min(1.), 1.); + } +} diff --git a/src/weather-service.rs b/src/weather-service.rs new file mode 100644 index 0000000..b2c9f0b --- /dev/null +++ b/src/weather-service.rs @@ -0,0 +1,354 @@ +//! Dedicated latest-job HTTP workers and an ordered cache writer. No request, +//! DNS/TLS work or cache write blocks the SDL thread. +use crate::i18n::Language; +use crate::weather::{Cache, Command, Location, Reading, parse_locations, parse_reading}; +use serde_json::Value; +use std::{ + fs, + io::Read, + net::SocketAddr, + path::{Path, PathBuf}, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicU64, Ordering}, + mpsc, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +const LIMIT: usize = 256 * 1024; + +#[derive(Clone)] +pub struct Client { + agent: ureq::Agent, + geocoding: String, + forecast: String, +} +impl Client { + pub fn new(test_server: Option<&str>) -> Result { + let (geocoding, forecast) = if let Some(server) = test_server { + let uri: ureq::http::Uri = server + .parse() + .map_err(|e| format!("Invalid weather test server: {e}"))?; + let address: SocketAddr = uri + .authority() + .ok_or("Weather test server needs a loopback address and port")? + .as_str() + .parse() + .map_err(|_| "Weather tests require an IP-literal loopback address and port")?; + if uri.scheme_str() != Some("http") + || !address.ip().is_loopback() + || address.port() == 0 + || uri.path() != "/" + || uri.query().is_some() + { + return Err("Weather test server must be an HTTP loopback origin".into()); + } + ( + format!("http://{address}/v1/search"), + format!("http://{address}/v1/forecast"), + ) + } else { + ( + "https://geocoding-api.open-meteo.com/v1/search".into(), + "https://api.open-meteo.com/v1/forecast".into(), + ) + }; + let agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(8))) + .max_redirects(0) + .proxy(None) + .build() + .new_agent(); + Ok(Self { + agent, + geocoding, + forecast, + }) + } + fn request(&self, endpoint: &str, query: &[(&str, &str)]) -> Result { + let mut request = self + .agent + .get(endpoint) + .header("Accept", "application/json") + .header("User-Agent", "LumaNative"); + for (key, value) in query { + request = request.query(key, value); + } + let mut response = request + .call() + .map_err(|_| "Open-Meteo is unavailable. Try again shortly.")?; + if !response.status().is_success() { + return Err("Open-Meteo is unavailable. Try again shortly.".into()); + } + let mut bytes = Vec::new(); + response + .body_mut() + .as_reader() + .take(LIMIT as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "The weather response could not be read")?; + if bytes.len() > LIMIT { + return Err("The weather response is too large".into()); + } + serde_json::from_slice(&bytes) + .map_err(|_| "The weather service returned unreadable data".into()) + } + pub fn search(&self, query: &str, language: Language) -> Result, String> { + let query = query.trim(); + if query.chars().count() < 2 { + return Err("Enter at least two characters.".into()); + } + parse_locations(&self.request( + &self.geocoding, + &[ + ("name", query), + ("count", "6"), + ("language", language.code()), + ("format", "json"), + ], + )?) + } + pub fn forecast(&self, location: &Location) -> Result { + let latitude = location.latitude.to_string(); + let longitude = location.longitude.to_string(); + parse_reading(&self.request( + &self.forecast, + &[ + ("latitude", &latitude), + ("longitude", &longitude), + ("current", "temperature_2m,is_day,weather_code"), + ("temperature_unit", "celsius"), + ("timezone", "auto"), + ], + )?) + } +} + +pub enum Response { + Search { + id: u64, + result: Result, String>, + }, + Forecast { + id: u64, + key: String, + result: Result, + }, +} +#[derive(Default)] +struct Pending { + job: Option, + closed: bool, +} +#[derive(Default)] +struct Queue { + pending: Mutex, + ready: Condvar, + generation: AtomicU64, +} +impl Queue { + fn submit(&self, id: u64, job: Command) { + self.generation.store(id, Ordering::SeqCst); + self.pending.lock().unwrap().job = Some(job); + self.ready.notify_one(); + } + fn close(&self) { + self.generation.store(u64::MAX, Ordering::SeqCst); + let mut pending = self.pending.lock().unwrap(); + pending.closed = true; + pending.job = None; + self.ready.notify_one(); + } +} +pub struct Service { + search: Arc, + forecast: Arc, + pub receiver: mpsc::Receiver, + cache: Option>>, + cache_worker: Option>, +} +impl Service { + pub fn new(client: Client, path: PathBuf) -> Self { + let (sender, receiver) = mpsc::channel(); + let spawn = |client: Client, sender: mpsc::Sender| { + let queue = Arc::new(Queue::default()); + let worker = queue.clone(); + thread::spawn(move || { + loop { + let job = { + let mut pending = worker.pending.lock().unwrap(); + while pending.job.is_none() && !pending.closed { + pending = worker.ready.wait(pending).unwrap(); + } + if pending.closed { + break; + } + pending.job.take().unwrap() + }; + let id = match &job { + Command::Search { id, .. } | Command::Forecast { id, .. } => *id, + _ => continue, + }; + if worker.generation.load(Ordering::SeqCst) != id { + continue; + } + let response = match job { + Command::Search { + id, + query, + language, + } => Response::Search { + id, + result: client.search(&query, language), + }, + Command::Forecast { id, location } => Response::Forecast { + id, + key: location.key(), + result: client.forecast(&location), + }, + _ => continue, + }; + if worker.generation.load(Ordering::SeqCst) == id + && sender.send(response).is_err() + { + break; + } + } + }); + queue + }; + let search = spawn(client.clone(), sender.clone()); + let forecast = spawn(client, sender); + let (cache, writes) = mpsc::channel::>(); + let cache_worker = thread::spawn(move || { + for cache in writes { + if let Some(cache) = cache { + let _ = save_cache(&path, &cache); + } else { + let _ = fs::remove_file(&path); + let _ = fs::remove_file(path.with_extension("json.tmp")); + } + } + }); + Self { + search, + forecast, + receiver, + cache: Some(cache), + cache_worker: Some(cache_worker), + } + } + pub fn generations(&self, (search, forecast): (u64, u64)) { + self.search.generation.store(search, Ordering::SeqCst); + self.forecast.generation.store(forecast, Ordering::SeqCst); + } + pub fn send(&self, command: Command) { + match command { + job @ Command::Search { id, .. } => self.search.submit(id, job), + job @ Command::Forecast { id, .. } => self.forecast.submit(id, job), + Command::Persist(cache) => { + if let Some(sender) = &self.cache { + let _ = sender.send(Some(cache)); + } + } + Command::ClearCache => { + if let Some(sender) = &self.cache { + let _ = sender.send(None); + } + } + } + } +} +impl Drop for Service { + fn drop(&mut self) { + self.search.close(); + self.forecast.close(); + self.cache.take(); + if let Some(worker) = self.cache_worker.take() { + let _ = worker.join(); + } + } +} +pub fn cache_path(preferences: &Path) -> PathBuf { + preferences.with_extension("weather.json") +} +pub fn load_cache(path: &Path) -> Option { + let mut bytes = Vec::new(); + fs::File::open(path) + .ok()? + .take(64 * 1024 + 1) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() > 64 * 1024 { + return None; + } + serde_json::from_slice(&bytes).ok() +} +pub(crate) fn save_cache(path: &Path, cache: &Cache) -> Result<(), String> { + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let temporary = path.with_extension("json.tmp"); + fs::write( + &temporary, + serde_json::to_vec(cache).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + fs::rename(temporary, path).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn mock_origins_are_loopback_only_and_never_follow_redirects() { + assert!(Client::new(Some("http://127.0.0.1:1234")).is_ok()); + assert!(Client::new(Some("http://[::1]:1234")).is_ok()); + for invalid in [ + "http://example.com:1234", + "https://127.0.0.1:1234", + "http://127.0.0.1:0", + "http://127.0.0.1:1234/path", + "http://user@127.0.0.1:1234", + "http://127.0.0.1:1234?query=yes", + ] { + assert!(Client::new(Some(invalid)).is_err(), "{invalid}"); + } + } + #[test] + fn cache_writes_are_ordered_and_flushed_before_shutdown() { + let directory = + std::env::temp_dir().join(format!("luma-weather-cache-{}", std::process::id())); + let path = directory.join("preferences.weather.json"); + let cache = Cache { + key: "48.2085,16.3721".into(), + updated_at: 1000, + reading: Reading { + temperature: 14.2, + code: 2, + is_day: true, + }, + }; + { + let service = Service::new( + Client::new(Some("http://127.0.0.1:1234")).unwrap(), + path.clone(), + ); + service.send(Command::Persist(cache.clone())); + service.send(Command::ClearCache); + service.send(Command::Persist(cache.clone())); + } + assert_eq!(load_cache(&path), Some(cache)); + { + let service = Service::new( + Client::new(Some("http://127.0.0.1:1234")).unwrap(), + path.clone(), + ); + service.send(Command::ClearCache); + } + assert!(!path.exists()); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/weather-tests.rs b/src/weather-tests.rs new file mode 100644 index 0000000..97d7153 --- /dev/null +++ b/src/weather-tests.rs @@ -0,0 +1,236 @@ +use super::*; +use serde_json::json; + +fn place() -> Location { + Location { + name: "Vienna".into(), + region: "Vienna".into(), + country: "Austria".into(), + latitude: 48.2085, + longitude: 16.3721, + } +} +fn reading() -> Reading { + Reading { + temperature: 14.2, + code: 2, + is_day: true, + } +} +fn preferences() -> Preferences { + Preferences { + weather_location: Some(place()), + ..Preferences::default() + } +} +fn cache(updated_at: i64) -> Cache { + Cache { + key: place().key(), + updated_at, + reading: reading(), + } +} + +#[test] +fn search_results_are_validated_normalized_and_deduplicated() { + let result = parse_locations(&json!({"results":[ + {"name":" Vienna\n", "admin1":"Vienna", "country":"Austria", "latitude":48.2085,"longitude":16.3721}, + {"name":"Duplicate", "latitude":48.2085,"longitude":16.3721}, + {"name":"Bad", "latitude":91,"longitude":16}, {"name":"", "latitude":0,"longitude":0} + ]})).unwrap(); + assert_eq!(result, vec![place()]); + assert_eq!(result[0].detail(), "Austria"); + assert!(parse_locations(&json!({})).unwrap().is_empty()); + assert!(parse_locations(&json!({"results":"wrong"})).is_err()); + assert!(parse_locations(&json!({"error":true})).is_err()); +} + +#[test] +fn temperatures_convert_the_unrounded_reading_and_use_web_rounding() { + assert_eq!(format_temperature(14.2, Unit::Fahrenheit), "58°F"); + assert_eq!(format_temperature(14.2, Unit::Celsius), "14°C"); + assert_eq!(format_temperature(-0.5, Unit::Celsius), "0°C"); + assert_eq!(format_temperature(-5.5, Unit::Celsius), "-5°C"); +} + +#[test] +fn api_readings_and_conditions_cover_invalid_data_and_night() { + let good = json!({"current":{"temperature_2m":14.2,"weather_code":2.0,"is_day":1.0}}); + assert_eq!(parse_reading(&good).unwrap(), reading()); + for value in [ + json!({}), + json!({"current":{"temperature_2m":90,"weather_code":2,"is_day":1}}), + json!({"current":{"temperature_2m":14,"weather_code":2.5,"is_day":1}}), + json!({"current":{"temperature_2m":14,"weather_code":2,"is_day":true}}), + ] { + assert!(parse_reading(&value).is_err()); + } + for (code, day, icon) in [ + (0, false, "moon"), + (0, true, "sun"), + (45, true, "fog"), + (56, true, "drizzle"), + (81, true, "rain"), + (85, true, "snow"), + (99, true, "storm"), + ] { + assert_eq!( + condition(Reading { + code, + is_day: day, + ..reading() + }) + .icon, + icon + ); + } +} + +#[test] +fn cache_reuse_and_units_do_not_trigger_another_request() { + let mut weather = Weather { + cache: Some(cache(1000)), + ..Weather::default() + }; + let mut preferences = preferences(); + assert!(weather.sync(&preferences, true, 1100).0.is_none()); + preferences.temperature_unit = Unit::Fahrenheit; + assert!(weather.sync(&preferences, true, 1200).0.is_none()); + assert_eq!(weather.reading(&place()).unwrap().temperature, 14.2); + assert!(matches!( + weather.sync(&preferences, true, 1900).0, + Some(Command::Forecast { .. }) + )); +} + +#[test] +fn no_requests_without_a_location_enabled_widget_and_visible_home() { + let mut weather = Weather::default(); + let mut preferences = preferences(); + preferences.show_weather = false; + assert!(weather.sync(&preferences, true, 1000).0.is_none()); + assert!(preferences.weather_location.is_some()); + preferences.show_weather = true; + assert!(weather.sync(&preferences, false, 1000).0.is_none()); + preferences.weather_location = None; + assert!(weather.sync(&preferences, true, 1000).0.is_none()); +} + +#[test] +fn weather_failure_keeps_valid_cache_labels_it_saved_and_retries_later() { + let mut weather = Weather { + cache: Some(cache(1000)), + ..Weather::default() + }; + let preferences = preferences(); + let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 2000).0 else { + panic!("Expected stale-cache refresh"); + }; + weather.accept_forecast(id, &location.key(), Err("offline".into()), 2000); + assert!(weather.failed); + assert!(weather.reading(&place()).is_some()); + assert!(weather.sync(&preferences, true, 2299).0.is_none()); + assert!(weather.sync(&preferences, true, 2300).0.is_some()); + assert!(cache(1000).age(999).is_none()); + assert!(cache(1000).age(1001 + MAX_CACHE_AGE).is_none()); + weather.sync(&preferences, true, 1001 + MAX_CACHE_AGE); + assert!(weather.cache.is_none()); +} + +#[test] +fn removed_disabled_or_changed_locations_reject_in_flight_results() { + let mut weather = Weather::default(); + let mut preferences = preferences(); + let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 1000).0 else { + panic!(); + }; + weather.remove_location(); + preferences.weather_location = None; + assert_eq!( + weather.accept_forecast(id, &location.key(), Ok(reading()), 1001), + (false, None) + ); + assert!(weather.cache.is_none()); + preferences.weather_location = Some(place()); + let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 1002).0 else { + panic!(); + }; + preferences.show_weather = false; + weather.sync(&preferences, true, 1003); + assert_eq!( + weather.accept_forecast(id, &location.key(), Ok(reading()), 1004), + (false, None) + ); + preferences.show_weather = true; + let Some(Command::Forecast { + id: old, + location: old_location, + }) = weather.sync(&preferences, true, 1005).0 + else { + panic!(); + }; + preferences.weather_location.as_mut().unwrap().latitude += 1.; + let Some(Command::Forecast { + id: new, + location: new_location, + }) = weather.sync(&preferences, true, 1006).0 + else { + panic!(); + }; + assert_eq!( + weather.accept_forecast(old, &old_location.key(), Ok(reading()), 1007), + (false, None) + ); + assert!( + weather + .accept_forecast(new, &new_location.key(), Ok(reading()), 1007) + .0 + ); + assert_eq!(weather.cache.as_ref().unwrap().key, new_location.key()); +} + +#[test] +fn returning_home_retries_stale_weather_without_waiting_for_the_old_retry_timer() { + let mut weather = Weather { + cache: Some(cache(1000)), + ..Weather::default() + }; + let preferences = preferences(); + let Some(Command::Forecast { id, location }) = weather.sync(&preferences, true, 2000).0 else { + panic!(); + }; + weather.accept_forecast(id, &location.key(), Err("offline".into()), 2000); + assert!(weather.sync(&preferences, false, 2010).0.is_none()); + assert!(weather.sync(&preferences, true, 2020).0.is_some()); +} + +#[test] +fn editing_query_invalidates_old_results_and_short_queries_do_not_search() { + let mut weather = Weather::default(); + weather.edit("V".into()); + assert!(weather.search(Language::En).is_none()); + assert!(weather.message.is_some()); + weather.edit("Vienna".into()); + let Some(Command::Search { id, .. }) = weather.search(Language::En) else { + panic!(); + }; + assert!(weather.search(Language::En).is_none()); + weather.edit("Paris".into()); + assert!(!weather.accept_search(id, Ok(vec![place()]))); + assert!(weather.results.is_empty()); +} + +#[test] +fn malformed_weather_preferences_do_not_lose_existing_favorites() { + let preferences: Preferences = serde_json::from_value(json!({ + "favorites":["netflix"], "weather_location":{"name":"Invalid","latitude":"oops","longitude":0}, + "temperature_unit":"kelvin", "show_weather":"off" + })).unwrap(); + assert_eq!(preferences.favorites, Some(vec!["netflix".into()])); + assert!(preferences.weather_location.is_none()); + assert_eq!(preferences.temperature_unit, Unit::Celsius); + assert!(preferences.show_weather); + let old: Preferences = serde_json::from_value(json!({"background":"forest"})).unwrap(); + assert!(old.show_weather); + assert!(old.weather_location.is_none()); +} diff --git a/src/weather.rs b/src/weather.rs new file mode 100644 index 0000000..dec2424 --- /dev/null +++ b/src/weather.rs @@ -0,0 +1,433 @@ +//! Open-Meteo data and request scheduling. API/cache temperatures remain Celsius; +//! presentation units never change the request or invalidate a cached reading. +use crate::i18n::Language; +use crate::model::Preferences; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::collections::HashSet; + +pub const REFRESH_SECONDS: i64 = 15 * 60; +pub const MAX_CACHE_AGE: i64 = 6 * 60 * 60; +const RETRY_SECONDS: i64 = 5 * 60; + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct Location { + pub name: String, + pub region: String, + pub country: String, + pub latitude: f64, + pub longitude: f64, +} +impl Location { + pub fn parse(value: &Value) -> Option { + let latitude = value.get("latitude")?.as_f64()?; + let longitude = value.get("longitude")?.as_f64()?; + let name = label(value.get("name")); + if name.is_empty() + || !latitude.is_finite() + || !longitude.is_finite() + || latitude.abs() > 90. + || longitude.abs() > 180. + { + return None; + } + Some(Self { + name, + region: label(value.get("region").or_else(|| value.get("admin1"))), + country: label(value.get("country")), + latitude, + longitude, + }) + } + pub fn key(&self) -> String { + format!( + "{:.4},{:.4}", + if self.latitude == 0. { + 0. + } else { + self.latitude + }, + if self.longitude == 0. { + 0. + } else { + self.longitude + } + ) + } + pub fn detail(&self) -> String { + let mut parts = Vec::new(); + for part in [&self.region, &self.country] { + if !part.is_empty() && part != &self.name && !parts.contains(&part.as_str()) { + parts.push(part.as_str()); + } + } + parts.join(", ") + } +} +fn label(value: Option<&Value>) -> String { + value + .and_then(Value::as_str) + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .filter(|c| !c.is_control()) + .take(120) + .collect() +} +pub fn deserialize_location<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Ok(Location::parse(&Value::deserialize(deserializer)?)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Unit { + #[default] + Celsius, + Fahrenheit, +} +impl Unit { + pub fn symbol(self) -> &'static str { + match self { + Self::Celsius => "°C", + Self::Fahrenheit => "°F", + } + } +} +pub fn deserialize_unit<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Ok( + if Value::deserialize(deserializer)?.as_str() == Some("fahrenheit") { + Unit::Fahrenheit + } else { + Unit::Celsius + }, + ) +} +pub fn deserialize_enabled<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Ok(Value::deserialize(deserializer)? != Value::Bool(false)) +} +pub fn format_temperature(celsius: f64, unit: Unit) -> String { + let value = if unit == Unit::Fahrenheit { + celsius * 9. / 5. + 32. + } else { + celsius + }; + // Match JavaScript Math.round, including negative half-degree ties. + format!("{}{}", (value + 0.5).floor() as i32, unit.symbol()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +pub struct Reading { + pub temperature: f64, + pub code: u8, + pub is_day: bool, +} +impl Reading { + pub fn valid(self) -> bool { + self.temperature.is_finite() + && (-100.0..=70.0).contains(&self.temperature) + && self.code <= 99 + } +} +pub fn parse_reading(value: &Value) -> Result { + let current = &value["current"]; + let temperature = current["temperature_2m"].as_f64(); + let code = current["weather_code"] + .as_f64() + .filter(|n| n.fract() == 0. && (0.0..=99.0).contains(n)); + let day = current["is_day"].as_f64().filter(|n| *n == 0. || *n == 1.); + if let (Some(temperature), Some(code), Some(day)) = (temperature, code, day) { + let reading = Reading { + temperature, + code: code as u8, + is_day: day == 1., + }; + if reading.valid() { + return Ok(reading); + } + } + Err("The weather service returned an unreadable reading.".into()) +} +pub fn parse_locations(value: &Value) -> Result, String> { + if !value.is_object() + || value["error"] == true + || value.get("results").is_some_and(|v| !v.is_array()) + { + return Err("The location service returned an unreadable response.".into()); + } + let mut seen = HashSet::new(); + Ok(value + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(20) + .filter_map(Location::parse) + .filter(|place| seen.insert(place.key())) + .take(6) + .collect()) +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct Cache { + pub key: String, + pub updated_at: i64, + pub reading: Reading, +} +impl Cache { + pub fn age(&self, now: i64) -> Option { + now.checked_sub(self.updated_at) + .filter(|age| (0..=MAX_CACHE_AGE).contains(age) && self.reading.valid()) + } +} + +pub struct Condition { + pub key: &'static str, + pub icon: &'static str, +} +pub fn condition(reading: Reading) -> Condition { + let (key, icon) = match reading.code { + 0 if reading.is_day => ("clearSkies", "sun"), + 0 => ("clearNight", "moon"), + 1 => ("mostlyClear", "partly-cloudy"), + 2 => ("partlyCloudy", "partly-cloudy"), + 3 => ("overcast", "cloud"), + 45 | 48 => ("fog", "fog"), + 51 | 53 | 55 | 56 | 57 => ("drizzle", "drizzle"), + 61 | 63 | 65 | 66 | 67 | 80 | 81 | 82 => ("rain", "rain"), + 71 | 73 | 75 | 77 | 85 | 86 => ("snow", "snow"), + 95 | 96 | 99 => ("thunderstorms", "storm"), + _ => ("currentWeather", "cloud"), + }; + Condition { key, icon } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Command { + Search { + id: u64, + query: String, + language: Language, + }, + Forecast { + id: u64, + location: Location, + }, + Persist(Cache), + ClearCache, +} + +#[derive(Default, Serialize)] +pub struct Weather { + pub query: String, + pub results: Vec, + pub searching: bool, + pub message: Option, + pub cache: Option, + pub failed: bool, + pub loading: bool, + #[serde(skip)] + search_id: u64, + #[serde(skip)] + forecast_id: u64, + #[serde(skip)] + active_key: Option, + #[serde(skip)] + pending: Option<(u64, String)>, + #[serde(skip)] + next_fetch: i64, + #[serde(skip)] + active_home: bool, +} +impl Weather { + pub fn generations(&self) -> (u64, u64) { + (self.search_id, self.forecast_id) + } + pub fn fetching(&self) -> bool { + self.pending.is_some() + } + pub fn edit(&mut self, query: String) { + self.query = query + .chars() + .filter(|c| !c.is_control()) + .take(120) + .collect(); + self.cancel_search(); + } + pub fn cancel_search(&mut self) { + self.search_id += 1; + self.results.clear(); + self.searching = false; + self.message = None; + } + pub fn search(&mut self, language: Language) -> Option { + if self.searching { + return None; + } + self.cancel_search(); + let query = self.query.trim().to_owned(); + if query.chars().count() < 2 { + self.message = Some("enterCity".into()); + return None; + } + self.searching = true; + Some(Command::Search { + id: self.search_id, + query, + language, + }) + } + pub fn accept_search(&mut self, id: u64, result: Result, String>) -> bool { + if id != self.search_id || !self.searching { + return false; + } + self.searching = false; + match result { + Ok(places) => { + self.results = places; + if self.results.is_empty() { + self.message = Some("noLocations".into()); + } + } + Err(_) => self.message = Some("searchUnavailable".into()), + } + true + } + pub fn invalidate_forecast(&mut self) { + self.forecast_id += 1; + self.pending = None; + self.active_key = None; + self.next_fetch = 0; + self.loading = false; + self.failed = false; + } + pub fn remove_location(&mut self) { + self.cancel_search(); + self.invalidate_forecast(); + self.cache = None; + } + pub fn reading(&self, location: &Location) -> Option { + self.cache + .as_ref() + .filter(|cache| cache.key == location.key()) + .map(|cache| cache.reading) + } + pub fn sync( + &mut self, + preferences: &Preferences, + home_visible: bool, + now: i64, + ) -> (Option, bool) { + let before = ( + self.loading, + self.failed, + self.cache.as_ref().map(|c| (c.updated_at, c.reading)), + ); + if self + .cache + .as_ref() + .is_some_and(|cache| cache.age(now).is_none()) + { + self.cache = None; + } + let key = preferences.weather_location.as_ref().map(Location::key); + if self.active_key != key { + self.invalidate_forecast(); + self.active_key = key.clone(); + } + let active = preferences.show_weather && home_visible && key.is_some(); + if active && !self.active_home { + self.next_fetch = 0; + } + self.active_home = active; + let mut command = None; + if !preferences.show_weather || !home_visible || key.is_none() { + if self.pending.take().is_some() { + self.forecast_id += 1; + } + self.loading = false; + } else if let Some(cache) = self.cache.as_ref().filter(|c| { + Some(&c.key) == key.as_ref() && c.age(now).is_some_and(|age| age < REFRESH_SECONDS) + }) { + self.loading = false; + self.failed = false; + self.next_fetch = cache.updated_at + REFRESH_SECONDS; + } else if self.pending.is_none() && now >= self.next_fetch { + self.forecast_id += 1; + self.pending = key.clone().map(|key| (self.forecast_id, key)); + self.loading = self + .cache + .as_ref() + .is_none_or(|cache| Some(&cache.key) != key.as_ref()); + command = preferences + .weather_location + .clone() + .map(|location| Command::Forecast { + id: self.forecast_id, + location, + }); + } + if active && self.pending.is_some() { + self.loading = self + .cache + .as_ref() + .is_none_or(|cache| Some(&cache.key) != key.as_ref()); + } + let changed = before + != ( + self.loading, + self.failed, + self.cache.as_ref().map(|c| (c.updated_at, c.reading)), + ); + (command, changed) + } + pub fn accept_forecast( + &mut self, + id: u64, + key: &str, + result: Result, + now: i64, + ) -> (bool, Option) { + if self + .pending + .as_ref() + .is_none_or(|(pending, location)| *pending != id || location != key) + { + return (false, None); + } + self.pending = None; + self.loading = false; + match result { + Ok(reading) if reading.valid() => { + let cache = Cache { + key: key.into(), + updated_at: now, + reading, + }; + self.cache = Some(cache.clone()); + self.failed = false; + self.next_fetch = now + REFRESH_SECONDS; + (true, Some(Command::Persist(cache))) + } + _ => { + self.failed = true; + self.next_fetch = now + RETRY_SECONDS; + if self + .cache + .as_ref() + .is_some_and(|cache| cache.age(now).is_none()) + { + self.cache = None; + } + (true, None) + } + } + } +} + +#[cfg(test)] +#[path = "weather-tests.rs"] +mod tests; diff --git a/src/webos-compat.rs b/src/webos-compat.rs new file mode 100644 index 0000000..57f43e7 --- /dev/null +++ b/src/webos-compat.rs @@ -0,0 +1,125 @@ +//! The webOS SDK's libc lacks getauxval, used by Rust's startup and ring. +//! Read real kernel auxiliary values rather than inventing CPU capabilities. + +fn read_auxv(fd: libc::c_int, kind: libc::c_ulong) -> Option { + loop { + let mut entry = [0 as libc::c_ulong; 2]; + // SAFETY: the byte slice covers exactly this initialized, writable entry. + let bytes = unsafe { + std::slice::from_raw_parts_mut( + entry.as_mut_ptr().cast::(), + std::mem::size_of_val(&entry), + ) + }; + let mut offset = 0; + while offset < bytes.len() { + // SAFETY: read receives a valid writable slice and never owns fd. + let count = unsafe { + libc::read( + fd, + bytes[offset..].as_mut_ptr().cast(), + bytes.len() - offset, + ) + }; + if count < 0 { + if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + return None; + } + if count == 0 { + return None; + } + offset += count as usize; + } + if entry[0] == 0 { + return None; + } + if entry[0] == kind { + return Some(entry[1]); + } + } +} + +#[cfg(target_os = "linux")] +#[unsafe(no_mangle)] +extern "C" fn getauxval(kind: libc::c_ulong) -> libc::c_ulong { + // SAFETY: errno is thread-local; open uses a static NUL-terminated pathname. + // This path needs no allocations, SDL, or initialized Rust application state. + unsafe { + let errno = libc::__errno_location(); + let saved_errno = *errno; + let fd = libc::open( + c"/proc/self/auxv".as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC, + ); + let value = if fd >= 0 { + let value = read_auxv(fd, kind); + libc::close(fd); + value + } else { + None + }; + *errno = if value.is_some() { + saved_errno + } else { + libc::ENOENT + }; + value.unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{io::Write, os::fd::AsRawFd, os::unix::net::UnixStream}; + + fn lookup( + entries: &[[libc::c_ulong; 2]], + truncate: usize, + key: libc::c_ulong, + ) -> Option { + let (reader, mut writer) = UnixStream::pair().unwrap(); + let bytes: Vec = entries + .iter() + .flatten() + .flat_map(|n| n.to_ne_bytes()) + .collect(); + writer.write_all(&bytes[..bytes.len() - truncate]).unwrap(); + writer.shutdown(std::net::Shutdown::Write).unwrap(); + read_auxv(reader.as_raw_fd(), key) + } + + #[test] + fn preserves_kernel_values_including_zero() { + let entries = [[6, 4096], [16, 0], [26, 17], [0, 0]]; + assert_eq!(lookup(&entries, 0, 6), Some(4096)); + assert_eq!(lookup(&entries, 0, 16), Some(0)); + assert_eq!(lookup(&entries, 0, 26), Some(17)); + assert_eq!(lookup(&entries, 0, 42), None); + } + + #[test] + fn rejects_terminator_and_incomplete_records() { + assert_eq!(lookup(&[[0, 0], [6, 4096]], 0, 6), None); + assert_eq!(lookup(&[[0, 0]], 0, 0), None); + assert_eq!(lookup(&[[6, 4096]], 1, 6), None); + assert_eq!(lookup(&[], 0, 6), None); + assert_eq!(read_auxv(-1, 6), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn exposes_real_page_size_and_libc_errno_contract() { + unsafe { + *libc::__errno_location() = libc::EACCES; + assert_eq!( + getauxval(libc::AT_PAGESZ), + libc::sysconf(libc::_SC_PAGESIZE) as libc::c_ulong + ); + assert_eq!(*libc::__errno_location(), libc::EACCES); + assert_eq!(getauxval(libc::c_ulong::MAX), 0); + assert_eq!(*libc::__errno_location(), libc::ENOENT); + } + } +} diff --git a/tests/brand-assets.test.mjs b/tests/brand-assets.test.mjs new file mode 100644 index 0000000..db6cc44 --- /dev/null +++ b/tests/brand-assets.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import test from 'node:test'; +import sharp from 'sharp'; +import { sources, prepareSvg } from '../tools/brand-assets.mjs'; + +test('every expanded brand has standalone, renderable vector artwork and pinned provenance', async () => { + assert.equal(sources.length, 27); + for (const [name, url, hash] of sources) { + assert.match(hash, /^[0-9a-f]{64}$/, name); + const svg = await fs.readFile(new URL(`../assets/logos/${name}.svg`, import.meta.url), 'utf8'); + assert.ok(svg.includes(url), `Missing source attribution for ${name}`); + assert.doesNotMatch(svg, / index % info.channels === 3 && value > 0), + `Empty logo: ${name}`, + ); + } +}); + +test('source preparation preserves paths and removes component syntax and redundant clipping', () => { + const svg = prepareSvg( + 'coupang-play', + 'export default () => ();', + 'https://example.com/source', + ); + assert.match(svg, /width="115"/); + assert.match(svg, /d="M0 0L1 1L1 0Z"/); + assert.doesNotMatch(svg, /export|style=|clip-path||=\{/); + assert.doesNotMatch( + prepareSvg('hulu', '', 'https://example.com/source'), + /<\?xml/, + ); +}); diff --git a/tests/brands-smoke.mjs b/tests/brands-smoke.mjs new file mode 100644 index 0000000..873cdff --- /dev/null +++ b/tests/brands-smoke.mjs @@ -0,0 +1,87 @@ +// Exercise every curated logo with the native desktop renderer. No TV, network +// service or weather API is used. The contact sheet is a manual design preview. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import sharp from 'sharp'; + +const root = path.resolve(import.meta.dirname, '..'); +const out = path.join(root, '.build'); +await fs.mkdir(out, { recursive: true }); +function run(command, args) { + const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', timeout: 120000 }); + if (result.error) throw result.error; + assert.equal(result.status, 0, `${command} failed`); +} +run('cargo', ['build', '--locked', '--package', 'luma', '--no-default-features']); +const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma'); +const scratch = await fs.mkdtemp(path.join(out, 'brand-gallery-')); +try { + const preferences = path.join(scratch, 'preferences.json'); + const dump = path.join(scratch, 'state.json'); + const diagnostics = path.join(scratch, 'render.json'); + const screenshot = path.join(scratch, 'card.png'); + await fs.writeFile(preferences, JSON.stringify({ reduced_motion: true, show_weather: false })); + run(binary, [ + '--preferences', + preferences, + '--script', + 'apps', + '--frames', + '18', + '--dump-state', + dump, + ]); + const { inventory } = JSON.parse(await fs.readFile(dump, 'utf8')); + const logos = (await fs.readdir(path.join(root, 'assets/logos'))) + .filter((file) => file.endsWith('.svg')) + .map((file) => path.parse(file).name) + .sort(); + const cards = []; + for (const [index, logo] of logos.entries()) { + const app = inventory.find((app) => app.brand === logo); + assert.ok(app, `Desktop demo is missing ${logo}`); + run(binary, [ + '--preferences', + preferences, + '--script', + `apps,type:${app.title},back`, + '--frames', + '18', + '--screenshot', + screenshot, + '--dump-state', + dump, + '--diagnostics', + diagnostics, + ]); + const state = JSON.parse(await fs.readFile(dump, 'utf8')); + const report = JSON.parse(await fs.readFile(diagnostics, 'utf8')); + assert.ok(state.apps.includes(app.id), `${logo} must be searchable in the demo`); + assert.equal(report.capture.missing_images, 0, `Missing artwork for ${logo}`); + assert.equal(report.capture.glyph_failures, 0, `Invalid caption for ${logo}`); + assert.ok(report.capture.vector_meshes > 0); + const input = await sharp(screenshot) + .extract({ left: 78, top: 264, width: 282, height: 238 }) + .png() + .toBuffer(); + cards.push({ input, left: (index % 6) * 282, top: Math.floor(index / 6) * 238 }); + } + await sharp({ + create: { + width: 6 * 282, + height: Math.ceil(cards.length / 6) * 238, + channels: 4, + background: '#11151b', + }, + }) + .composite(cards) + .png() + .toFile(path.join(out, 'brands-gallery.png')); + console.log( + `All ${logos.length} SVG-backed demo apps rendered. Contact sheet: .build/brands-gallery.png`, + ); +} finally { + await fs.rm(scratch, { recursive: true, force: true }); +} diff --git a/tests/fixtures/app-visibility.json b/tests/fixtures/app-visibility.json new file mode 100644 index 0000000..4b8cdbc --- /dev/null +++ b/tests/fixtures/app-visibility.json @@ -0,0 +1,37 @@ +[ + { "app": { "id": "coupangplay", "visible": true }, "include": true }, + { "app": { "id": "org.litefin.app" }, "include": true }, + { "app": { "id": "com.example.hidden", "visible": false }, "include": false }, + { "app": { "id": "com.example.hidden-string", "visible": "false" }, "include": false }, + { "app": { "id": "com.example.string-true", "visible": "true" }, "include": true }, + { "app": { "id": "com.example.null-visible", "visible": null }, "include": true }, + { "app": { "id": "com.webos.app.igallery", "visible": true }, "include": true }, + { "app": { "id": "com.webos.app.roomconnect", "visible": true }, "include": true }, + { "app": { "id": "com.webos.app.hidden", "visible": false }, "include": false }, + { "app": { "id": "com.webos.app.unlisted" }, "include": false }, + { "app": { "id": "com.webos.app.string-true", "visible": "true" }, "include": false }, + { "app": { "id": "com.palm.app.visible", "visible": true }, "include": true }, + { "app": { "id": "com.palm.app.hidden", "visible": false }, "include": false }, + { "app": { "id": "com.lge.app.visible", "visible": true }, "include": true }, + { "app": { "id": "com.lge.app.unlisted" }, "include": false }, + { "app": { "id": "com.webos.app.livetv", "visible": false }, "include": true }, + { "app": { "id": "com.ecklf.luma", "visible": true }, "include": false }, + { "app": { "id": "com.luma.native", "visible": true }, "include": false }, + { "app": { "id": "com.luma.launcher", "visible": true }, "include": false }, + { "app": { "id": "com.luma.launcher.native", "visible": true }, "include": false }, + { "app": { "id": "com.webos.app.home", "visible": true }, "include": false }, + { "app": { "id": "com.webos.app.hdmi1", "visible": true }, "include": false }, + { "app": { "id": "com.webos.app.externalinput.av1", "visible": true }, "include": false }, + { "app": { "id": "com.example.duplicate", "visible": false }, "include": false }, + { "app": { "id": "com.example.duplicate", "visible": true }, "include": true }, + { "app": { "id": "com.example.duplicate", "visible": false }, "include": false }, + { "app": { "id": "com.example.first-visible" }, "include": true }, + { "app": { "id": "com.example.first-visible", "visible": false }, "include": false }, + { "app": { "id": "constructor" }, "include": true }, + { "app": { "id": "" }, "include": false }, + { "app": { "id": "com..invalid", "visible": true }, "include": false }, + { "app": { "id": "bad;command", "visible": true }, "include": false }, + { "app": { "id": "com.example.한국어", "visible": true }, "include": false }, + { "app": { "id": 123, "visible": true }, "include": false }, + { "app": { "title": "Missing ID", "visible": true }, "include": false } +] diff --git a/tests/home-splash.test.mjs b/tests/home-splash.test.mjs new file mode 100644 index 0000000..1a9d753 --- /dev/null +++ b/tests/home-splash.test.mjs @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; + +const script = await fs.readFile(new URL('../tv/home.cjs', import.meta.url), 'utf8'); +const app = 'com.ecklf.luma'; +const stage = '/var/lib/luma-home/app'; +const target = '/usr/palm/applications/com.webos.app.home'; +const developer = `/media/developer/${app}/preferences.json`; +const shared = `/media/developer/temp/${app}/preferences.json`; +const internal = `/media/internal/.${app}/preferences.json`; + +function fixture({ production = false, mounted = false, preferences = {} } = {}) { + const appDir = `/media/${production ? 'cryptofs' : 'developer'}/apps/usr/palm/applications/${app}`; + const files = new Map([ + [stage, Buffer.alloc(0)], + [`${stage}/appinfo.json`, Buffer.from('{}')], + [`${appDir}/luma`, Buffer.from('mock executable')], + [`${appDir}/splash.png`, Buffer.from('default')], + ...['dunes', 'forest', 'prism'].map((name) => [ + `${appDir}/splash/${name}.png`, + Buffer.from(name), + ]), + ...Object.entries(preferences).map(([file, value]) => [ + file, + Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)), + ]), + ]); + const writes = []; + const logs = []; + const missing = () => Object.assign(new Error('Missing'), { code: 'ENOENT' }); + const mockFs = { + existsSync: (file) => files.has(file), + readFileSync(file, encoding) { + if (!files.has(file)) throw missing(); + const data = files.get(file); + return encoding ? data.toString(encoding) : data; + }, + writeFileSync(file, data) { + writes.push(file); + files.set(file, data); + }, + renameSync(from, to) { + assert.ok(files.has(from)); + files.set(to, files.get(from)); + files.delete(from); + }, + statSync(file) { + assert.ok([stage, target].includes(file)); + return { dev: 1, ino: mounted || file === stage ? 1 : 2 }; + }, + readlinkSync() { + throw missing(); + }, + }; + const context = vm.createContext({ + require(name) { + if (name === 'fs') return mockFs; + if (name === 'path') return path.posix; + assert.equal(name, 'child_process'); + const unexpected = () => assert.fail('Tests must not invoke any system or TV command'); + return { execFileSync: unexpected, spawnSync: unexpected }; + }, + process: { argv: ['node', 'home.cjs', 'status'] }, + console: { log: (line) => logs.push(line), error: (line) => logs.push(line) }, + }); + vm.runInContext(script, context, { timeout: 2000 }); + assert.equal(context.process.exitCode, undefined); + return { + context, + files, + writes, + appDir, + splash() { + context.stageSplash(appDir); + assert.ok(!files.has(`${stage}/splash.png.new`)); + return files.get(`${stage}/splash.png`).toString(); + }, + }; +} + +test('production and developer installs follow native preference precedence', () => { + const preferences = { + [developer]: { background: 'forest' }, + [shared]: { background: 'dunes' }, + [internal]: { background: 'prism' }, + }; + assert.equal(fixture({ production: true, preferences }).splash(), 'prism'); + assert.equal(fixture({ preferences }).splash(), 'forest'); +}); + +test('an existing persistent destination wins before installation-local migration', () => { + const persistent = fixture({ preferences: { [internal]: { background: 'prism' } } }); + persistent.files.set( + `${persistent.appDir}/preferences.json`, + Buffer.from(JSON.stringify({ background: 'forest' })), + ); + assert.equal(persistent.splash(), 'prism'); + const legacy = fixture(); + legacy.files.set( + `${legacy.appDir}/preferences.json`, + Buffer.from(JSON.stringify({ background: 'forest' })), + ); + assert.equal(legacy.splash(), 'forest'); +}); + +test('missing, malformed, invalid and unavailable backgrounds have a default splash', () => { + for (const saved of [undefined, '{', { background: '../forest' }, { background: 'unknown' }]) { + const mock = fixture({ preferences: saved === undefined ? {} : { [developer]: saved } }); + assert.equal(mock.splash(), saved?.background === 'unknown' ? 'default' : 'dunes'); + } +}); + +test('apply refreshes the staged image even while the Home mount is active', () => { + const mock = fixture({ mounted: true, preferences: { [developer]: { background: 'prism' } } }); + mock.files.set(`${stage}/splash.png`, Buffer.from('old background')); + const calls = []; + Object.assign(mock.context, { + foreignMount: () => false, + foreground: () => 'some.other.app', + reread: () => calls.push('reread'), + stopHome: () => calls.push('stop'), + launchHome: () => assert.fail('Applying must not steal focus from another app'), + }); + mock.context.apply({ appDir: mock.appDir }, false); + assert.equal(mock.files.get(`${stage}/splash.png`).toString(), 'prism'); + assert.deepEqual(mock.writes, [`${stage}/splash.png.new`]); + assert.deepEqual(calls, ['reread', 'stop']); +}); + +function closeFixture({ running, closeReply = { returnValue: true } }) { + const mock = fixture(); + const calls = []; + let queries = 0; + let pauses = 0; + Object.assign(mock.context, { + luna(method, payload) { + calls.push([method, { ...payload }]); + if (method === 'running') { + return { returnValue: true, running: running[Math.min(queries++, running.length - 1)] }; + } + // The old close({id: APP}) call causes "no app matched by pid" on TV. + assert.equal(method, 'closeByAppId'); + assert.equal(payload.id, app); + if (closeReply instanceof Error) throw closeReply; + return closeReply; + }, + pause() { + pauses++; + }, + }); + return { ...mock, calls, pauses: () => pauses }; +} + +test('takeover closes the normal native instance by app ID and waits for its exit', () => { + const normal = { id: app, processId: '1234' }; + const other = { id: 'some.other.app', processId: '5678' }; + const mock = closeFixture({ running: [[normal, other], [normal, other], [other]] }); + mock.context.closeNormal(); + assert.deepEqual(mock.calls, [ + ['running', {}], + ['closeByAppId', { id: app }], + ['running', {}], + ['running', {}], + ]); + assert.equal(mock.pauses(), 1); +}); + +test('takeover skips closing when the normal instance is not running', () => { + const mock = closeFixture({ running: [[{ id: 'com.webos.app.home' }]] }); + mock.context.closeNormal(); + assert.deepEqual(mock.calls, [['running', {}]]); + assert.equal(mock.pauses(), 0); +}); + +test('an instance exiting before the close reply does not block takeover', () => { + for (const closeReply of [ + { returnValue: false, errorCode: 1, errorText: 'no app matched by pid' }, + new Error('Luna close request timed out'), + ]) { + const mock = closeFixture({ running: [[{ id: app }], []], closeReply }); + mock.context.closeNormal(); + assert.equal(mock.calls.length, 3); + assert.equal(mock.pauses(), 0); + } +}); + +test('close failures remain visible when the normal instance is still running', () => { + const mock = closeFixture({ + running: [[{ id: app }]], + closeReply: { returnValue: false, errorText: 'Permission denied' }, + }); + assert.throws( + () => mock.context.closeNormal(), + /Close the normal Luma instance.*Permission denied/, + ); + assert.equal(mock.pauses(), 0); +}); + +test('takeover stops waiting after five seconds if the normal instance stays running', () => { + const mock = closeFixture({ running: [[{ id: app }]] }); + assert.throws(() => mock.context.closeNormal(), /Close Luma before enabling Home integration/); + assert.equal(mock.pauses(), 5); +}); diff --git a/tests/read-app-icon.test.mjs b/tests/read-app-icon.test.mjs new file mode 100644 index 0000000..7c659ad --- /dev/null +++ b/tests/read-app-icon.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; + +const script = await fs.readFile(new URL('../tv/read-app-icon.cjs', import.meta.url), 'utf8'); +const roots = [ + '/media/developer/apps/usr/palm/applications', + '/media/cryptofs/apps/usr/palm/applications', + '/usr/palm/applications', +]; +const directory = `${roots[0]}/sample`; + +function readIcon({ metadata = {}, installations = [], chunk = 16384 } = {}) { + const files = new Map(); + for (const install of installations) { + const dir = install.directory ?? directory; + if (install.manifest !== undefined) { + files.set(`${dir}/appinfo.json`, Buffer.from(JSON.stringify(install.manifest))); + } + for (const [name, bytes] of Object.entries(install.icons ?? {})) { + files.set(path.posix.resolve(dir, name), Buffer.from(bytes)); + } + } + const opened = []; + const descriptors = new Set(); + const logs = []; + const errors = []; + const mockFs = { + openSync(file) { + opened.push(file); + if (!files.has(file)) throw Object.assign(new Error('Missing'), { code: 'ENOENT' }); + descriptors.add(file); + return file; + }, + fstatSync(file) { + return { size: files.get(file).length, isFile: () => true }; + }, + readSync(file, buffer, offset, length, position) { + return files.get(file).copy(buffer, offset, position, position + Math.min(length, chunk)); + }, + closeSync(file) { + assert.ok(descriptors.delete(file)); + }, + }; + vm.runInNewContext( + script, + { + require(name) { + if (name === 'fs') return mockFs; + assert.equal(name, 'path'); + return path.posix; + }, + process: { argv: ['node', JSON.stringify({ id: 'sample', ...metadata })] }, + Buffer, + console: { log: (line) => logs.push(line), error: (line) => errors.push(line) }, + }, + { timeout: 2000 }, + ); + assert.equal(descriptors.size, 0, 'all opened files must be closed'); + assert.equal(logs.length, 1); + const reply = JSON.parse(logs[0]); + assert.equal(reply.returnValue, true); + return { icons: reply.icons.map((hex) => Buffer.from(hex, 'hex')), opened, errors }; +} + +test('runtime helper discovers manifest artwork without any bundled icon map', () => { + const { icons } = readIcon({ + installations: [ + { + manifest: { id: 'sample', largeIcon: 'resources/hu/large.png', icon: 'regular.png' }, + icons: { 'resources/hu/large.png': 'large', 'regular.png': 'regular' }, + }, + ], + chunk: 3, + }); + assert.deepEqual( + icons.map((bytes) => bytes.toString()), + ['large', 'regular'], + ); +}); + +test('live inventory installation and localized icon take precedence over other copies', () => { + const installed = `${roots[1]}/actual-directory`; + const { icons } = readIcon({ + metadata: { directory: installed, large_icon: `file://${installed}/localized.webp` }, + installations: [ + { icons: { 'icon.png': 'old developer install' } }, + { directory: installed, icons: { 'localized.webp': 'current installed artwork' } }, + ], + }); + assert.equal(icons[0].toString(), 'current installed artwork'); +}); + +test('missing, unsupported and oversized large icons fall back to regular or default artwork', () => { + for (const [name, bytes] of [ + ['missing.png', null], + ['large.svg', ''], + ['large.png', Buffer.alloc(512 * 1024 + 1)], + ]) { + const { icons } = readIcon({ + installations: [ + { + manifest: { id: 'sample', largeIcon: name, icon: 'regular.jpeg' }, + icons: { ...(bytes ? { [name]: bytes } : {}), 'regular.jpeg': 'regular' }, + }, + ], + }); + assert.deepEqual( + icons.map((data) => data.toString()), + ['regular'], + ); + } + const { icons } = readIcon({ installations: [{ icons: { 'icon.png': 'default' } }] }); + assert.equal(icons[0].toString(), 'default'); +}); + +test('artwork responses have an aggregate byte limit and do not duplicate the same path', () => { + const { icons } = readIcon({ + metadata: { large_icon: 'large.png', icon: 'regular.png' }, + installations: [ + { + manifest: { id: 'sample', largeIcon: 'large.png', icon: 'regular.png' }, + icons: { + 'large.png': Buffer.alloc(400 * 1024), + 'regular.png': Buffer.alloc(200 * 1024), + 'icon.png': 'default', + }, + }, + ], + }); + assert.deepEqual( + icons.map((data) => data.length), + [400 * 1024, 7], + ); +}); + +test('invalid paths and unrelated manifests cannot select artwork outside app directories', () => { + for (const icon of [ + '/etc/passwd.png', + '../../secret.png', + 'https://example.com/icon.png', + 'bad\0.png', + ]) { + const { icons, opened } = readIcon({ + metadata: { directory: '/etc', large_icon: icon }, + installations: [ + { + manifest: { id: 'another-app', largeIcon: 'wrong.png' }, + icons: { 'wrong.png': 'wrong' }, + }, + ], + }); + assert.deepEqual(icons, []); + assert.ok(opened.every((file) => roots.some((root) => file.startsWith(`${root}/`)))); + assert.ok(!opened.some((file) => file.endsWith('/wrong.png'))); + } + assert.throws(() => readIcon({ metadata: { id: 'sample; command' } }), /Invalid application ID/); + const { icons } = readIcon({ + installations: [{ manifest: null, icons: { 'icon.png': 'default' } }], + }); + assert.equal(icons[0].toString(), 'default'); +}); + +test('file names containing shell punctuation are treated as literal local paths', () => { + const name = "it's; $(not-a-command).png"; + const { icons } = readIcon({ + metadata: { icon: name }, + installations: [{ icons: { [name]: 'literal file' } }], + }); + assert.equal(icons[0].toString(), 'literal file'); +}); diff --git a/tests/release.test.mjs b/tests/release.test.mjs new file mode 100644 index 0000000..51ef2ec --- /dev/null +++ b/tests/release.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { syncVersions } from '../tools/sync-version.mjs'; +import { isReleaseMerge, isVersionIncrease } from '../tools/release-gate.mjs'; + +const root = path.resolve(import.meta.dirname, '..'); +const metadata = ['appinfo.json', 'Cargo.toml', 'Cargo.lock', 'readme.md']; + +async function fixture(t, version = '2.0.4') { + const scratch = path.join(root, '.build'); + await fs.mkdir(scratch, { recursive: true }); + const directory = await fs.mkdtemp(path.join(scratch, 'release-test-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const project = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); + project.version = '1.2.3'; + await fs.writeFile(path.join(directory, 'package.json'), JSON.stringify(project)); + await Promise.all( + metadata.map((filename) => + fs.copyFile(path.join(root, filename), path.join(directory, filename)), + ), + ); + await syncVersions(directory); + project.version = version; + await fs.writeFile(path.join(directory, 'package.json'), JSON.stringify(project)); + return directory; +} + +const readMetadata = (directory) => + Promise.all(metadata.map((filename) => fs.readFile(path.join(directory, filename), 'utf8'))); + +test('version sync changes only release versions and is idempotent', async (t) => { + const directory = await fixture(t, '3.1.0'); + const before = await readMetadata(directory); + await assert.rejects(syncVersions(directory, { check: true }), /out of sync/); + assert.deepEqual(await readMetadata(directory), before, 'check mode must not write files'); + + assert.deepEqual((await syncVersions(directory)).changed, metadata); + const after = await readMetadata(directory); + const previous = JSON.parse(before[0]).version; + assert.equal(JSON.parse(after[0]).version, '3.1.0'); + assert.equal(after[0], before[0].replace(`"version": "${previous}"`, '"version": "3.1.0"')); + assert.equal(after[1], before[1].replace(`version = "${previous}"`, 'version = "3.1.0"')); + assert.equal( + after[2], + before[2].replace(`name = "luma"\nversion = "${previous}"`, 'name = "luma"\nversion = "3.1.0"'), + 'dependency versions and helper crates must be preserved', + ); + assert.equal( + after[3], + before[3].replaceAll(`com.ecklf.luma_${previous}_arm.ipk`, 'com.ecklf.luma_3.1.0_arm.ipk'), + ); + assert.deepEqual((await syncVersions(directory, { check: true })).changed, []); + assert.deepEqual((await syncVersions(directory)).changed, []); +}); + +test('invalid release versions and missing packages fail before writing metadata', async (t) => { + for (const version of ['3.0.0-beta.1', '3.0', '03.0.0']) { + const directory = await fixture(t, version); + const before = await readMetadata(directory); + await assert.rejects(syncVersions(directory), /numeric major.minor.patch/); + assert.deepEqual(await readMetadata(directory), before); + } + const directory = await fixture(t); + const lockfile = path.join(directory, 'Cargo.lock'); + await fs.writeFile( + lockfile, + (await fs.readFile(lockfile, 'utf8')).replace('name = "luma"', 'name = "other"'), + ); + const before = await readMetadata(directory); + await assert.rejects(syncVersions(directory), /one luma package in Cargo.lock/); + assert.deepEqual(await readMetadata(directory), before); +}); + +test('the real release command combines changesets and synchronizes every file', async (t) => { + const directory = await fixture(t, '1.2.3'); + await syncVersions(directory); + await fs.mkdir(path.join(directory, 'tools')); + await fs.mkdir(path.join(directory, '.changeset')); + for (const filename of [ + 'tools/sync-version.mjs', + '.changeset/config.json', + 'pnpm-workspace.yaml', + '.oxfmtrc.json', + ]) { + await fs.copyFile(path.join(root, filename), path.join(directory, filename)); + } + await fs.symlink(path.join(root, 'node_modules'), path.join(directory, 'node_modules'), 'dir'); + await fs.writeFile( + path.join(directory, '.changeset/fix.md'), + '---\n"lg-luma": patch\n---\n\nFix release behavior.\n', + ); + await fs.writeFile( + path.join(directory, '.changeset/feature.md'), + '---\n"lg-luma": minor\n---\n\nAdd a release feature.\n', + ); + const result = spawnSync('pnpm', ['version:release'], { + cwd: directory, + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, CI: 'true' }, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal((await syncVersions(directory, { check: true })).version, '1.3.0'); + const changelog = await fs.readFile(path.join(directory, 'CHANGELOG.md'), 'utf8'); + assert.match(changelog, /1\.3\.0/); + assert.match(changelog, /Fix release behavior/); + assert.match(changelog, /Add a release feature/); + assert.deepEqual(await fs.readdir(path.join(directory, '.changeset')), ['config.json']); +}); + +function gateFixture({ pull = {}, previous = '2.0.3', current = '2.0.4', event = {} } = {}) { + const context = { + eventName: 'push', + ref: 'refs/heads/main', + repo: { owner: 'ecklf', repo: 'lg-luma' }, + sha: 'merged-commit', + payload: { before: 'previous-commit', forced: false, deleted: false }, + ...event, + }; + const releasePull = { + merged_at: '2026-10-23T12:00:00Z', + merge_commit_sha: context.sha, + base: { ref: 'main', repo: { full_name: 'ecklf/lg-luma' } }, + head: { ref: 'changeset-release/main', repo: { full_name: 'ecklf/lg-luma' } }, + ...pull, + }; + const reads = []; + const github = { + paginate: async (_method, parameters) => { + assert.equal(parameters.commit_sha, context.sha); + return [releasePull]; + }, + rest: { + repos: { + listPullRequestsAssociatedWithCommit() {}, + async getContent({ ref, path: filename }) { + assert.equal(filename, 'package.json'); + reads.push(ref); + const version = ref === context.sha ? current : previous; + return { + data: { + encoding: 'base64', + content: Buffer.from(JSON.stringify({ version })).toString('base64'), + }, + }; + }, + }, + }, + }; + return { github, context, reads }; +} + +test('a merged same-repository version PR with an increased version can publish', async () => { + const fixture = gateFixture(); + assert.equal(await isReleaseMerge(fixture), true); + assert.deepEqual(fixture.reads, ['previous-commit', 'merged-commit']); +}); + +test('ordinary merges, unmerged PRs, forks and manual runs cannot publish', async (t) => { + const cases = [ + ['feature PR', { pull: { head: { ref: 'feature', repo: { full_name: 'ecklf/lg-luma' } } } }], + ['unmerged version PR', { pull: { merged_at: null } }], + ['different merge commit', { pull: { merge_commit_sha: 'other-commit' } }], + [ + 'fork version branch', + { pull: { head: { ref: 'changeset-release/main', repo: { full_name: 'other/lg-luma' } } } }, + ], + ['deleted head repository', { pull: { head: { ref: 'changeset-release/main', repo: null } } }], + [ + 'different base', + { pull: { base: { ref: 'develop', repo: { full_name: 'ecklf/lg-luma' } } } }, + ], + ['manual CI run', { event: { eventName: 'workflow_dispatch' } }], + ['PR checks', { event: { eventName: 'pull_request' } }], + ['other branch', { event: { ref: 'refs/heads/changeset-release/main' } }], + ['force push', { event: { payload: { before: 'previous-commit', forced: true } } }], + [ + 'initial push', + { event: { payload: { before: '0000000000000000000000000000000000000000' } } }, + ], + ]; + for (const [name, options] of cases) { + await t.test(name, async () => { + const fixture = gateFixture(options); + assert.equal(await isReleaseMerge(fixture), false); + assert.deepEqual(fixture.reads, []); + }); + } +}); + +test('publishing requires a numeric version increase', async () => { + for (const current of ['2.0.3', '2.0.2', '1.99.99']) { + assert.equal(await isReleaseMerge(gateFixture({ current })), false); + } + for (const [previous, current] of [ + ['2.0.3', '2.0.4'], + ['2.9.9', '2.10.0'], + ['2.99.99', '3.0.0'], + ]) { + assert.equal(isVersionIncrease(previous, current), true); + } + await assert.rejects( + isReleaseMerge(gateFixture({ current: '3.0.0-beta.1' })), + /Invalid numeric release version/, + ); +}); + +test('missing PRs and failed GitHub lookups never permit publication', async () => { + const fixture = gateFixture(); + fixture.github.paginate = async () => []; + assert.equal(await isReleaseMerge(fixture), false); + assert.deepEqual(fixture.reads, []); + fixture.github.paginate = async () => { + throw new Error('GitHub unavailable'); + }; + await assert.rejects(isReleaseMerge(fixture), /GitHub unavailable/); +}); diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..985ecd0 --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import sharp from 'sharp'; + +const root = path.resolve(import.meta.dirname, '..'); +const out = path.join(root, '.build'); +await fs.mkdir(out, { recursive: true }); +const run = (command, args) => { + const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', timeout: 120000 }); + if (result.error) throw result.error; + assert.equal(result.status, 0, `${command} failed`); +}; +run('cargo', ['build', '--locked', '--package', 'luma']); +const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma'); +const temporary = await fs.mkdtemp(path.join(out, 'smoke-')); +try { + const preferences = path.join(temporary, 'preferences.json'); + const scenario = async (name, script, extra = []) => { + const screenshot = path.join(out, `${name}.png`); + const dump = path.join(temporary, `${name}.json`); + const diagnostics = path.join(out, `${name}-render.json`); + run(binary, [ + '--preferences', + preferences, + '--script', + script, + '--frames', + '90', + '--screenshot', + screenshot, + '--dump-state', + dump, + '--diagnostics', + diagnostics, + ...extra, + ]); + const state = JSON.parse(await fs.readFile(dump, 'utf8')); + const render = JSON.parse(await fs.readFile(diagnostics, 'utf8')); + assert.equal( + render.capture.glyph_failures, + 0, + 'Visible vector text must remain valid across cache reuse', + ); + assert.equal(render.last_presented.glyph_failures, 0); + assert.equal(render.capture.font_antialiasing, 'vector grayscale coverage'); + assert.equal(render.capture.missing_glyphs, 0); + assert.equal(render.capture.missing_images, 0); + state.render = render; + assert.equal(state.error, null); + const stats = await sharp(screenshot).stats(); + assert.equal( + stats.channels[3].min, + 255, + 'UI compositing must preserve opaque framebuffer alpha', + ); + assert.ok( + stats.channels.slice(0, 3).some((channel) => channel.stdev > 10), + 'Framebuffer must contain rendered UI', + ); + return state; + }; + const home = await scenario('home', 'down'); + assert.deepEqual(home.focus, { App: 'netflix' }); + const hidden = await scenario('apps', 'apps,down,menu,down,enter'); + assert.ok(hidden.preferences.hidden.includes('netflix')); + assert.deepEqual(hidden.focus, { App: 'youtube.leanback.v4' }); + // Reload from disk, find Netflix in Hidden, and restore it. + const restored = await scenario( + 'restored', + 'apps,down,up,right,right,right,right,enter,down,menu,down,enter', + ); + assert.deepEqual(restored.preferences.hidden, []); + const searched = await scenario('search', 'apps,type:YouTube,back'); + assert.equal(searched.query, 'YouTube'); + assert.deepEqual(searched.apps, ['youtube.leanback.v4']); + for (const [id, title] of [ + ['coupangplay', 'Coupang Play'], + ['com.frograms.watchaplay.webos', 'Watcha Play'], + ['pooq', 'Wavve'], + ['com.cmb.rainbowtv', 'Rainbow TV'], + ['com.lgtvod.kr.app', 'LG TV VOD'], + ]) { + const koreanApp = await scenario(`korean-${id}`, `apps,type:${title},back`); + assert.deepEqual(koreanApp.apps, [id]); + assert.ok(koreanApp.ui_text.includes(title)); + } + const settings = await scenario('settings', 'settings,down,down,enter,down,enter'); + assert.equal(settings.preferences.background, 'forest'); + const saved = await scenario('saved', 'down,hold,right,back'); + assert.equal(saved.preferences.background, 'forest'); + assert.deepEqual(saved.preferences.favorites.slice(0, 2), ['youtube.leanback.v4', 'netflix']); + const clock = await scenario('clock', 'settings,click:900:284'); + assert.equal(clock.subpage, 'Clock'); + assert.equal(clock.focus, 'ClockFormat'); + assert.ok(clock.ui_text.includes('Clock format')); + assert.ok(clock.ui_text.includes('Time format')); + const input = await scenario('inputs', 'settings,down,down,down,down,enter,enter,up,enter'); + assert.equal(input.preferences.input_modes['com.webos.app.hdmi1'], 'Off'); + assert.equal(input.subpage, null); + const closed = await scenario('dialog-closed', 'apps,down,menu,click:100:900'); + assert.equal(closed.modal, false); + assert.deepEqual(closed.focus, { App: 'youtube.leanback.v4' }); + const fixedTime = ['--time', '2026-10-21T20:41:00+02:00']; + const one = await scenario('density-1x', 'home,down', fixedTime); + const two = await scenario('density-2x', 'home,down', [...fixedTime, '--capture-scale', '2']); + assert.deepEqual(two.render.capture.target, { width: 3840, height: 2160 }); + assert.equal(two.render.capture.viewport.scale, 2); + assert.equal(one.render.capture.viewport.scale, 1); + assert.equal(two.render.capture.max_logical_font_px, one.render.capture.max_logical_font_px); + for (const capture of [one.render.capture, two.render.capture]) { + assert.ok( + Math.abs( + capture.max_physical_font_px - capture.max_logical_font_px * capture.viewport.scale, + ) <= 0.126, + ); + } + assert.ok( + two.render.capture.max_physical_font_px > one.render.capture.max_physical_font_px * 1.9, + ); + assert.ok(two.render.capture.cached_glyphs > 0); + assert.ok(two.render.capture.cached_text_runs > 0); + assert.ok(two.render.capture.text_geometry_bytes > 0); + assert.ok(two.render.capture.text_triangles > 0); + assert.equal( + two.render.drawable_is_4k, + two.render.drawable.width >= 3840 && two.render.drawable.height >= 2160, + ); + const uhd = await sharp(path.join(out, 'density-2x.png')).metadata(); + assert.equal(uhd.width, 3840); + assert.equal(uhd.height, 2160); + const low = await sharp(path.join(out, 'density-1x.png')) + .extract({ left: 84, top: 265, width: 600, height: 96 }) + .resize(1200, 192, { kernel: 'cubic' }) + .raw() + .toBuffer(); + const high = await sharp(path.join(out, 'density-2x.png')) + .extract({ left: 168, top: 530, width: 1200, height: 192 }) + .raw() + .toBuffer(); + assert.notDeepEqual(high, low, '4K vector text must use the drawable pixel scale'); + const hasWallpaper = await fs.access(path.join(out, 'assets/wallpapers/forest@2x.jpg')).then( + () => true, + () => false, + ); + assert.equal( + two.render.capture.high_resolution_wallpaper, + hasWallpaper && two.render.graphics.max_texture_size >= 3840, + ); + const profiled = await scenario('profile', 'home,down', ['--profile-frames', '24', ...fixedTime]); + assert.equal(profiled.render.profile.presented, 24); + assert.ok(profiled.render.profile.pacing.samples > 0); + assert.ok(profiled.render.profile.cpu_submission.mean_ms >= 0); + const greetingEditor = 'settings,down,enter,down,down,down,down,enter,enter'; + for (const name of ['Ana', 'Bob']) { + const named = await scenario( + `greeting-${name}`, + `${greetingEditor},type:${name},back,home`, + fixedTime, + ); + assert.equal(named.preferences.greeting_name, name); + assert.ok(named.ui_text.includes(`Good evening, ${name}.`)); + assert.equal( + JSON.parse(await fs.readFile(preferences, 'utf8')).greeting_name, + name, + 'Keyboard completion must save without waiting for another SDL input event', + ); + } + const greeting = await scenario( + 'greeting-settings', + 'settings,down,enter,down,down,down,down,enter,down', + ); + assert.equal(greeting.subpage, 'Greeting'); + assert.equal(greeting.focus, 'GreetingNameClear'); + assert.ok(greeting.ui_text.includes('Clear name')); + + const appNamesEditor = 'settings,down,enter,enter'; + const appNames = await scenario('app-name-settings', appNamesEditor); + assert.equal(appNames.subpage, 'AppNames'); + assert.ok(appNames.ui_text.includes('Show on Home')); + assert.ok(appNames.ui_text.includes('Show on Apps')); + assert.equal(appNames.preferences.show_home_names, false); + assert.equal(appNames.preferences.show_app_names, true); + const customNames = await scenario( + 'home-names-enabled', + `${appNamesEditor},enter,down,enter,back,home`, + ); + assert.equal(customNames.preferences.show_home_names, true); + assert.equal(customNames.preferences.show_app_names, false); + assert.ok(customNames.ui_text.includes('Netflix')); + const hiddenNames = await scenario('app-names-disabled', 'apps'); + assert.equal(hiddenNames.preferences.show_app_names, false); + assert.ok(!hiddenNames.ui_text.includes('Netflix')); + + const renamedInput = await scenario( + 'input-rename-focus', + 'settings,down,down,down,down,enter,down,enter,down,type:Console,back', + ); + assert.equal(renamedInput.focus, 'InputRename'); + assert.equal(renamedInput.preferences.input_names['com.webos.app.hdmi2'], 'Console'); + assert.ok(renamedInput.ui_text.includes('Clear name')); + const clockEditor = 'settings,down,enter,down,enter'; + const timeFormat = await scenario('clock-time-format', `${clockEditor},down,enter`); + assert.equal(timeFormat.subpage, 'TimeFormat'); + assert.deepEqual(timeFormat.focus, { Choice: 0 }); + assert.ok(timeFormat.ui_text.includes('HH:MM')); + assert.ok(timeFormat.ui_text.includes('HH:MM:SS')); + const withSeconds = await scenario( + 'clock-time-format-saved', + `${clockEditor},down,enter,down,enter`, + ); + assert.equal(withSeconds.subpage, 'Clock'); + assert.equal(withSeconds.focus, 'ClockTimeFormat'); + assert.equal(withSeconds.preferences.show_seconds, true); + assert.equal(withSeconds.preferences.hour_24, false); + const clockFocus = await scenario('clock-format-focus', `${clockEditor},enter,down`); + assert.equal(clockFocus.subpage, 'ClockFormat'); + assert.deepEqual(clockFocus.focus, { Choice: 1 }); + const system = await scenario('system-settings', 'settings,down,down,down,down,down,right,down'); + assert.deepEqual(system.focus, { Setting: 5 }); + assert.ok(system.ui_text.includes('Refresh apps & inputs')); + assert.ok(system.ui_text.includes('Quit app')); + console.log( + 'Native smoke checks passed: rendering/alpha, navigation, persistence, SDL pointer input, DPI-aware glyphs, 4K capture and presentation profiling.', + ); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/tests/sync-splash.test.mjs b/tests/sync-splash.test.mjs new file mode 100644 index 0000000..8bb8bdd --- /dev/null +++ b/tests/sync-splash.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; + +const script = await fs.readFile(new URL('../tv/sync-splash.cjs', import.meta.url), 'utf8'); +const app = 'com.ecklf.luma'; +const state = '/var/lib/luma-home'; + +function fixture({ production = false, config, failWrite = false } = {}) { + const directory = `/media/${production ? 'cryptofs' : 'developer'}/apps/usr/palm/applications/${app}`; + const files = new Map([ + [ + `${directory}/appinfo.json`, + Buffer.from(JSON.stringify({ id: app, type: 'native', main: 'luma' })), + ], + [`${directory}/splash.png`, Buffer.from('dunes')], + [`${directory}/splash/forest.png`, Buffer.from('forest')], + [`${directory}/splash/prism.png`, Buffer.from('prism')], + [`${state}/app/splash.png`, Buffer.from('old Home splash')], + ]); + if (config) files.set(`${state}/state.json`, Buffer.from(JSON.stringify(config(directory)))); + const writes = []; + const renames = []; + const missing = () => Object.assign(new Error('Missing'), { code: 'ENOENT' }); + const mockFs = { + readFileSync(file, encoding) { + if (!files.has(file)) throw missing(); + const data = files.get(file); + return encoding ? data.toString(encoding) : data; + }, + writeFileSync(file, data, options) { + assert.ok(file.endsWith('/splash.png.new')); + assert.equal(options.mode, 0o644); + if (failWrite) throw Object.assign(new Error('Read-only filesystem'), { code: 'EROFS' }); + writes.push(file); + files.set(file, data); + }, + renameSync(from, to) { + assert.ok(files.has(from)); + renames.push(to); + files.set(to, files.get(from)); + files.delete(from); + }, + }; + return { + directory, + files, + writes, + renames, + sync(background, overrides = {}) { + const logs = []; + vm.runInNewContext( + script, + { + require(name) { + if (name === 'fs') return mockFs; + assert.equal(name, 'path', 'the helper must not invoke system or TV commands'); + return path.posix; + }, + process: { argv: ['node', JSON.stringify({ directory, background, ...overrides })] }, + console: { log: (line) => logs.push(line) }, + }, + { timeout: 2000 }, + ); + assert.deepEqual(logs.map(JSON.parse), [{ returnValue: true, background }]); + }, + }; +} + +test('normal developer and production tile launches use the saved selection', () => { + for (const production of [false, true]) { + const mock = fixture({ production }); + mock.sync('forest'); + assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'forest'); + mock.sync('prism'); + assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'prism'); + assert.deepEqual(mock.renames, [ + `${mock.directory}/splash.png`, + `${mock.directory}/splash.png`, + ]); + assert.ok(!mock.files.has(`${mock.directory}/splash.png.new`)); + assert.equal(mock.files.get(`${state}/app/splash.png`).toString(), 'old Home splash'); + } +}); + +test('only an enabled Home stage belonging to this installation is refreshed', () => { + for (const [enabled, matching] of [ + [true, true], + [false, true], + [true, false], + ]) { + const mock = fixture({ + config: (directory) => ({ enabled, appDir: matching ? directory : '/another-installation' }), + }); + mock.sync('prism'); + assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'prism'); + assert.equal( + mock.files.get(`${state}/app/splash.png`).toString(), + enabled && matching ? 'prism' : 'old Home splash', + ); + } +}); + +test('startup repairs a replaced package splash without rewriting an unchanged Home image', () => { + const mock = fixture({ config: (directory) => ({ enabled: true, appDir: directory }) }); + mock.sync('forest'); + mock.writes.length = 0; + mock.sync('forest'); + assert.deepEqual(mock.writes, []); + mock.files.set(`${mock.directory}/splash.png`, Buffer.from('package update default')); + mock.sync('forest'); + assert.deepEqual(mock.writes, [`${mock.directory}/splash.png.new`]); + assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'forest'); +}); + +test('missing sources and failed writes preserve the previous launch splash', () => { + for (const failWrite of [false, true]) { + const mock = fixture({ failWrite }); + assert.throws(() => mock.sync(failWrite ? 'forest' : 'missing'), /Missing|Read-only/); + assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'dunes'); + assert.equal(mock.files.get(`${state}/app/splash.png`).toString(), 'old Home splash'); + assert.deepEqual(mock.renames, []); + } +}); + +test('unrelated installations and invalid selections cannot update a launch splash', () => { + const mock = fixture(); + for (const background of ['../forest', '', 'forest.png', null]) { + assert.throws(() => mock.sync(background), /Invalid splash background/); + } + for (const directory of ['/etc', `${mock.directory}/..`, `${mock.directory}-other`]) { + assert.throws(() => mock.sync('forest', { directory }), /Invalid Luma installation/); + } + mock.files.set( + `${mock.directory}/appinfo.json`, + Buffer.from(JSON.stringify({ id: 'another.app', type: 'native', main: 'luma' })), + ); + assert.throws(() => mock.sync('forest'), /installed native Luma package/); + assert.deepEqual(mock.writes, []); +}); diff --git a/tests/translations-smoke.mjs b/tests/translations-smoke.mjs new file mode 100644 index 0000000..a9a9a7a --- /dev/null +++ b/tests/translations-smoke.mjs @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import messages from '../assets/messages.json' with { type: 'json' }; + +const root = path.resolve(import.meta.dirname, '..'); +const out = path.join(root, '.build'); +await fs.mkdir(out, { recursive: true }); +const temporary = await fs.mkdtemp(path.join(out, 'translations-smoke-')); +const preferences = path.join(temporary, 'preferences.json'); +const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma'); +const requests = []; +const places = { + en: { name: 'Vienna', region: 'Vienna', country: 'Austria' }, + de: { name: 'Wien', region: 'Wien', country: 'Österreich' }, + zh: { name: '维也纳', region: '维也纳', country: '奥地利' }, + pt: { name: 'Viena', region: 'Viena', country: 'Áustria' }, +}; +const server = http.createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1'); + requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams) }); + response.setHeader('Content-Type', 'application/json'); + if (url.pathname === '/v1/search') { + const place = places[url.searchParams.get('language')]; + assert.ok(place, 'Geocoding must use a supported UI language'); + response.end( + JSON.stringify({ + results: [{ ...place, admin1: place.region, latitude: 48.2085, longitude: 16.3721 }], + }), + ); + } else if (url.pathname === '/v1/forecast') { + assert.equal(url.searchParams.get('temperature_unit'), 'celsius'); + response.end(JSON.stringify({ current: { temperature_2m: 14.2, weather_code: 2, is_day: 1 } })); + } else { + response.writeHead(404); + response.end('{}'); + } +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +async function run(command, args) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('Translation smoke child timed out')); + }, 45000); + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.on('exit', (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error(`${command} exited ${code}`)); + }); + }); +} +async function scenario(name, script, extra = []) { + const dump = path.join(temporary, `${name}.json`); + const report = path.join(out, `language-${name}-render.json`); + await run(binary, [ + '--preferences', + preferences, + '--weather-test-server', + origin, + '--script', + script, + '--frames', + '120', + '--time', + '2026-10-21T20:41:00+02:00', + '--screenshot', + path.join(out, `language-${name}.png`), + '--dump-state', + dump, + '--diagnostics', + report, + ...extra, + ]); + const state = JSON.parse(await fs.readFile(dump, 'utf8')); + const render = JSON.parse(await fs.readFile(report, 'utf8')); + assert.equal(state.error, null); + assert.equal( + render.capture.missing_glyphs, + 0, + 'Translated text must not become missing-character boxes', + ); + assert.equal(render.capture.glyph_failures, 0); + assert.equal( + render.capture.missing_images, + 0, + 'Settings icons and other translated-screen artwork must be present', + ); + return state; +} +function contains(state, key, language) { + assert.ok(state.ui_text.includes(messages[key][language]), `Missing rendered ${key}/${language}`); +} +const picker = 'settings,down,enter,down,down,down,enter'; +const location = 'settings,down,down,down,enter,down,enter'; +try { + await run('cargo', ['build', '--locked', '--package', 'luma']); + await fs.writeFile( + preferences, + JSON.stringify({ language: 'en', show_home_names: true, reduced_motion: true }), + ); + const languages = await scenario('picker-en', picker); + assert.equal(languages.subpage, 'Language'); + for (const name of ['English', 'Deutsch', '简体中文', 'Español', 'Português (Brasil)']) + assert.ok(languages.ui_text.includes(name)); + + const german = await scenario('de-home', `${picker},down,enter,home,down`); + assert.equal(german.preferences.language, 'de'); + for (const key of [ + 'home', + 'settings', + 'favorites', + 'inputsHeading', + 'evening', + 'setWeatherLocation', + ]) + contains(german, key, 'de'); + assert.ok(german.ui_text.includes('Netflix'), 'Installed app names stay intact'); + const persisted = await scenario('de-settings', 'settings,down'); + assert.equal(persisted.preferences.language, 'de'); + contains(persisted, 'language', 'de'); + + const chinese = await scenario('zh-home', `${picker},down,enter,home,down`); + assert.equal(chinese.preferences.language, 'zh'); + for (const key of [ + 'home', + 'apps', + 'settings', + 'favorites', + 'inputsHeading', + 'evening', + 'setWeatherLocation', + ]) + contains(chinese, key, 'zh'); + assert.ok(chinese.ui_text.includes('Netflix')); + assert.ok(chinese.ui_text.includes('10月21日星期三')); + const chineseSettings = await scenario('zh-settings', 'settings,down'); + contains(chineseSettings, 'language', 'zh'); + contains(chineseSettings, 'appNames', 'zh'); + + const selected = await scenario( + 'zh-weather', + `${location},type:Vienna,back,enter,await-search,down,enter,home,await-weather`, + ); + assert.equal(selected.preferences.weather_location.name, '维也纳'); + contains(selected, 'partlyCloudy', 'zh'); + assert.equal(requests.find((request) => request.path === '/v1/search').query.language, 'zh'); + const forecasts = requests.filter((request) => request.path === '/v1/forecast').length; + const changed = await scenario('de-weather', `${picker},up,enter,home,await-weather`); + assert.equal(changed.preferences.language, 'de'); + contains(changed, 'partlyCloudy', 'de'); + assert.ok(changed.ui_text.includes('维也纳'), 'The saved city name is data, not a UI label'); + assert.equal( + requests.filter((request) => request.path === '/v1/forecast').length, + forecasts, + 'Language changes reuse weather cache', + ); + const germanPlace = await scenario( + 'de-search', + `${location},type:Wien,back,enter,await-search,down,enter`, + ); + assert.equal(germanPlace.preferences.weather_location.name, 'Wien'); + assert.equal( + requests.filter((request) => request.path === '/v1/search').at(-1).query.language, + 'de', + ); + + const germanDialog = await scenario('de-dialog', 'apps,down,menu'); + contains(germanDialog, 'removeFavorite', 'de'); + contains(germanDialog, 'hideApp', 'de'); + const saved = JSON.parse(await fs.readFile(preferences, 'utf8')); + await fs.writeFile(preferences, JSON.stringify({ ...saved, language: 'zh' })); + const chineseDialog = await scenario('zh-dialog', 'apps,down,menu'); + contains(chineseDialog, 'removeFavorite', 'zh'); + contains(chineseDialog, 'categoryStreaming', 'zh'); + const input = await scenario('zh-input-options', 'settings,down,down,down,down,enter,enter'); + for (const key of ['stateOn', 'stateOff', 'stateAuto', 'inputWhenConnected']) + contains(input, key, 'zh'); + const background = await scenario('zh-background', 'settings,down,down,enter'); + for (const key of ['slate', 'midnight', 'monumentValley', 'northernLights']) + contains(background, key, 'zh'); + const keyboard = await scenario('zh-keyboard', 'apps,type:Netflix'); + for (const key of ['findApp', 'space', 'delete', 'clear', 'done']) contains(keyboard, key, 'zh'); + const weatherSettings = await scenario('zh-weather-settings', 'settings,down,down,down,enter'); + for (const key of ['showWidget', 'location', 'temperature']) contains(weatherSettings, key, 'zh'); + assert.ok( + !weatherSettings.ui_text.some((label) => label.includes('Open-Meteo')), + 'Weather credit is not shown in the UI', + ); + await scenario('zh-4k', 'home,down', ['--capture-scale', '2']); + + await fs.writeFile( + preferences, + JSON.stringify({ language: 'en', show_home_names: true, reduced_motion: true }), + ); + const portuguese = await scenario('pt-home', `${picker},down,down,down,down,enter,home,down`); + assert.equal(portuguese.preferences.language, 'pt'); + for (const key of [ + 'home', + 'apps', + 'settings', + 'favorites', + 'inputsHeading', + 'evening', + 'setWeatherLocation', + ]) + contains(portuguese, key, 'pt'); + assert.ok(portuguese.ui_text.includes('QUARTA-FEIRA, 21 DE OUTUBRO')); + assert.ok( + portuguese.ui_text.includes('Netflix'), + 'Installed app names stay intact in Portuguese', + ); + const portugueseSettings = await scenario('pt-settings', 'settings,down'); + assert.equal( + portugueseSettings.preferences.language, + 'pt', + 'Portuguese is restored after restart', + ); + assert.ok(portugueseSettings.ui_text.includes('Português (Brasil)')); + for (const key of ['language', 'appNames', 'clock']) contains(portugueseSettings, key, 'pt'); + assert.equal(JSON.parse(await fs.readFile(preferences, 'utf8')).language, 'pt'); + + const portugueseDialog = await scenario('pt-dialog', 'apps,down,menu'); + for (const key of ['removeFavorite', 'hideApp', 'categoryStreaming']) + contains(portugueseDialog, key, 'pt'); + const portugueseKeyboard = await scenario('pt-keyboard', 'apps,type:Netflix'); + for (const key of ['findApp', 'space', 'delete', 'clear', 'done']) + contains(portugueseKeyboard, key, 'pt'); + const portugueseInputs = await scenario( + 'pt-input-options', + 'settings,down,down,down,down,enter,enter', + ); + for (const key of ['stateOn', 'stateOff', 'stateAuto', 'inputWhenConnected']) + contains(portugueseInputs, key, 'pt'); + + const portugueseWeather = await scenario( + 'pt-weather', + `${location},type:Viena,back,enter,await-search,down,enter,home,await-weather`, + ); + assert.equal(portugueseWeather.preferences.language, 'pt'); + assert.equal(portugueseWeather.preferences.weather_location.name, 'Viena'); + assert.equal(portugueseWeather.preferences.weather_location.country, 'Áustria'); + contains(portugueseWeather, 'partlyCloudy', 'pt'); + assert.equal( + requests.filter((request) => request.path === '/v1/search').at(-1).query.language, + 'pt', + 'Geocoding must use the Portuguese locale', + ); + console.log( + 'Native translation checks passed: saved language selection, shared labels, Portuguese locale formatting and accented glyphs, localized geocoding/weather, unchanged app names, Chinese glyphs and 4K rendering.', + ); +} finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/tests/weather-smoke.mjs b/tests/weather-smoke.mjs new file mode 100644 index 0000000..9f79b8d --- /dev/null +++ b/tests/weather-smoke.mjs @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import sharp from 'sharp'; + +const root = path.resolve(import.meta.dirname, '..'); +const out = path.join(root, '.build'); +await fs.mkdir(out, { recursive: true }); +const temporary = await fs.mkdtemp(path.join(out, 'weather-smoke-')); +const preferences = path.join(temporary, 'preferences.json'); +const cache = path.join(temporary, 'preferences.weather.json'); +const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma'); +const vienna = { + name: 'Vienna', + region: 'Vienna', + country: 'Austria', + latitude: 48.2085, + longitude: 16.3721, +}; +const paris = { + name: 'Paris', + region: 'Île-de-France', + country: 'France', + latitude: 48.8534, + longitude: 2.3488, +}; +const reading = { temperature: 14.2, code: 2, is_day: true }; +const requests = []; +let forecastMode = 'ok'; +const server = http.createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1'); + requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams) }); + assert.equal(request.headers.cookie, undefined); + assert.equal(request.headers.referer, undefined); + response.on('error', () => {}); + const json = (value, status = 200) => { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(value)); + }; + if (url.pathname === '/v1/search') { + const query = url.searchParams.get('name'); + if (query === 'fail') return json({}, 503); + if (query === 'nowhere') return json({ results: [] }); + if (query === 'malformed') return json({ results: 'invalid' }); + if (query === 'oversized') return json({ results: [], padding: 'x'.repeat(300000) }); + if (query === 'redirect') { + response.writeHead(302, { Location: '/unexpected' }); + response.end(); + return; + } + const place = query === 'Paris' ? paris : vienna; + const send = () => + json({ + results: [ + { ...place, admin1: place.region }, + { ...place, admin1: place.region }, + { name: 'Invalid', latitude: 123, longitude: 16 }, + ], + }); + if (query === 'slow') { + const timer = setTimeout(send, 700); + response.on('close', () => clearTimeout(timer)); + } else send(); + return; + } + if (url.pathname === '/v1/forecast') { + assert.equal(url.searchParams.get('temperature_unit'), 'celsius'); + assert.equal(url.searchParams.get('current'), 'temperature_2m,is_day,weather_code'); + if (forecastMode === 'fail') return json({}, 503); + if (forecastMode === 'malformed') + return json({ current: { temperature_2m: 500, weather_code: 2, is_day: 1 } }); + const send = () => json({ current: { temperature_2m: 14.2, weather_code: 2, is_day: 1 } }); + if (forecastMode === 'slow') { + const timer = setTimeout(send, 700); + response.on('close', () => clearTimeout(timer)); + } else send(); + return; + } + json({}, 404); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; + +async function run(command, args) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }); + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('Weather smoke child timed out')); + }, 30000); + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('exit', (code) => { + clearTimeout(timeout); + if (code === 0) resolve(); + else reject(new Error(`${command} exited ${code}`)); + }); + }); +} +async function writePreferences(patch = {}) { + await fs.writeFile( + preferences, + JSON.stringify({ background: 'dunes', reduced_motion: true, show_weather: true, ...patch }), + ); +} +async function writeCache(age = 0) { + await fs.writeFile( + cache, + JSON.stringify({ + key: '48.2085,16.3721', + updated_at: Math.floor(Date.now() / 1000) - age, + reading, + }), + ); +} +async function scenario(name, script, frames = 210) { + const dump = path.join(temporary, `${name}.json`); + const screenshot = path.join(out, `weather-${name}.png`); + await run(binary, [ + '--preferences', + preferences, + '--weather-test-server', + origin, + '--script', + script, + '--frames', + String(frames), + '--time', + '2026-10-21T20:41:00+02:00', + '--screenshot', + screenshot, + '--dump-state', + dump, + ]); + const state = JSON.parse(await fs.readFile(dump, 'utf8')); + assert.equal(state.error, null); + assert.equal((await sharp(screenshot).stats()).channels[3].min, 255); + return state; +} +const forecastCount = () => requests.filter((request) => request.path === '/v1/forecast').length; +const searchCount = () => requests.filter((request) => request.path === '/v1/search').length; +const locationPage = 'settings,down,down,down,enter,down,enter'; +try { + await run('cargo', ['build', '--locked', '--package', 'luma']); + await writePreferences(); + await scenario('unconfigured', 'home,down'); + assert.equal(requests.length, 0, 'No geolocation or automatic city discovery'); + const short = await scenario('short-query', `${locationPage},enter,back,enter`); + assert.equal(short.weather.searching, false); + assert.ok(short.weather.message); + assert.equal(searchCount(), 0); + + const chosen = await scenario( + 'choose-location', + `${locationPage},type:1000 Wien & west/?,back,enter,await-search,down,enter,home,await-weather`, + ); + assert.equal(chosen.preferences.weather_location.name, 'Vienna'); + assert.equal(chosen.weather.cache.reading.temperature, 14.2); + assert.equal(chosen.weather.failed, false); + assert.equal(searchCount(), 1); + assert.equal(forecastCount(), 1); + assert.equal( + requests.find((request) => request.path === '/v1/search').query.name, + '1000 Wien & west/?', + ); + assert.equal(JSON.parse(await fs.readFile(cache, 'utf8')).reading.temperature, 14.2); + + await scenario('cache-reopen', 'home,await-weather'); + assert.equal(forecastCount(), 1); + const fahrenheit = await scenario( + 'fahrenheit', + 'settings,down,down,down,enter,down,down,enter,down,enter,home,await-weather', + ); + assert.equal(fahrenheit.preferences.temperature_unit, 'fahrenheit'); + assert.equal(fahrenheit.weather.cache.reading.temperature, 14.2); + assert.equal(forecastCount(), 1); + const hidden = await scenario('widget-off', 'settings,down,down,down,enter,enter,home'); + assert.equal(hidden.preferences.show_weather, false); + assert.equal(hidden.preferences.weather_location.name, 'Vienna'); + assert.equal(forecastCount(), 1); + await writeCache(20 * 60); + await scenario('disabled-stale-cache', 'home'); + assert.equal(forecastCount(), 1, 'A disabled widget must not refresh even stale weather'); + + await writePreferences({ weather_location: vienna }); + await writeCache(20 * 60); + forecastMode = 'fail'; + const offline = await scenario('saved-offline', 'home,await-weather'); + assert.equal(offline.weather.failed, true); + assert.equal(offline.weather.cache.reading.temperature, 14.2); + assert.equal(forecastCount(), 2); + await fs.rm(cache); + const unavailable = await scenario('unavailable', 'home,await-weather'); + assert.equal(unavailable.weather.failed, true); + assert.equal(unavailable.weather.cache, null); + assert.equal(forecastCount(), 3); + await writeCache(20 * 60); + forecastMode = 'malformed'; + const invalid = await scenario('invalid-reading', 'home,await-weather'); + assert.equal(invalid.weather.failed, true); + assert.equal(invalid.weather.cache.reading.temperature, 14.2); + await fs.rm(cache); + + forecastMode = 'slow'; + const removed = await scenario('remove-in-flight', `home,${locationPage},click:1716:201,home`); + assert.equal(removed.preferences.weather_location, null); + assert.equal(removed.weather.cache, null); + await assert.rejects(fs.access(cache)); + + await writePreferences({ weather_location: vienna }); + await writeCache(7 * 60 * 60); + forecastMode = 'fail'; + const expired = await scenario('expired-cache', 'home,await-weather'); + assert.equal(expired.weather.cache, null); + assert.equal(expired.weather.failed, true); + + await writePreferences(); + for (const query of ['nowhere', 'fail', 'malformed', 'oversized', 'redirect']) { + const state = await scenario(query, `${locationPage},type:${query},back,enter,await-search`); + assert.ok(state.weather.message); + assert.equal(state.weather.results.length, 0); + } + assert.ok( + !requests.some((request) => request.path === '/unexpected'), + 'No redirects to another endpoint', + ); + const latest = await scenario( + 'latest-search', + `${locationPage},type:slow,back,enter,click:900:197,click:1364:726,type:Paris,back,enter,await-search,down,enter`, + ); + assert.equal(latest.preferences.weather_location.name, 'Paris'); + assert.ok(latest.weather.results.length === 0); + console.log( + 'Native weather smoke checks passed: explicit search, request encoding, cached restart, units, visibility, offline/expired data, removal races, response limits and stale-search suppression.', + ); +} finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/tools/assets.mjs b/tools/assets.mjs new file mode 100644 index 0000000..7c2f50c --- /dev/null +++ b/tools/assets.mjs @@ -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 = ` + + + `; + // 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' + ? '' + : ``; + await sharp( + Buffer.from( + `${fill}`, + ), + ) + .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)}`); diff --git a/tools/brand-assets.mjs b/tools/brand-assets.mjs new file mode 100644 index 0000000..8b397f1 --- /dev/null +++ b/tools/brand-assets.mjs @@ -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('/, ''); + if (name === 'crunchyroll') { + svg = svg.replace('') + 6) + .replace(/style=\{\{[\s\S]*?\}\}/g, '') + .replace(/=\{([\d.]+)\}/g, '="$1"') + .replace(/[\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(/]*>[\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 `\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()); + } +} diff --git a/tools/build-webos.sh b/tools/build-webos.sh new file mode 100644 index 0000000..d993376 --- /dev/null +++ b/tools/build-webos.sh @@ -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 diff --git a/tools/deploy.mjs b/tools/deploy.mjs new file mode 100644 index 0000000..e6d4b57 --- /dev/null +++ b/tools/deploy.mjs @@ -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); +} diff --git a/tools/font-assets.mjs b/tools/font-assets.mjs new file mode 100644 index 0000000..0de457c --- /dev/null +++ b/tools/font-assets.mjs @@ -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}`); +} diff --git a/tools/font-weights.py b/tools/font-weights.py new file mode 100644 index 0000000..7f4bc29 --- /dev/null +++ b/tools/font-weights.py @@ -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") diff --git a/tools/ipk.mjs b/tools/ipk.mjs new file mode 100644 index 0000000..fc1f349 --- /dev/null +++ b/tools/ipk.mjs @@ -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() !== '!\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('!\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); +} diff --git a/tools/package.mjs b/tools/package.mjs new file mode 100644 index 0000000..0350c68 --- /dev/null +++ b/tools/package.mjs @@ -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}`); diff --git a/tools/release-gate.mjs b/tools/release-gate.mjs new file mode 100644 index 0000000..39467a8 --- /dev/null +++ b/tools/release-gate.mjs @@ -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); +} diff --git a/tools/repository.mjs b/tools/repository.mjs new file mode 100644 index 0000000..67b6326 --- /dev/null +++ b/tools/repository.mjs @@ -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)`); diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs new file mode 100644 index 0000000..d6613ea --- /dev/null +++ b/tools/screenshots.mjs @@ -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 }); +} diff --git a/tools/setup-webos-sdk.mjs b/tools/setup-webos-sdk.mjs new file mode 100644 index 0000000..2404ed3 --- /dev/null +++ b/tools/setup-webos-sdk.mjs @@ -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}`); diff --git a/tools/sync-version.mjs b/tools/sync-version.mjs new file mode 100644 index 0000000..6178513 --- /dev/null +++ b/tools/sync-version.mjs @@ -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'}.`, + ); +} diff --git a/tools/vector-assets.mjs b/tools/vector-assets.mjs new file mode 100644 index 0000000..5015f47 --- /dev/null +++ b/tools/vector-assets.mjs @@ -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(); +} diff --git a/tools/vector-assets/Cargo.toml b/tools/vector-assets/Cargo.toml new file mode 100644 index 0000000..2164631 --- /dev/null +++ b/tools/vector-assets/Cargo.toml @@ -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 } diff --git a/tools/vector-assets/src/main.rs b/tools/vector-assets/src/main.rs new file mode 100644 index 0000000..aa03f89 --- /dev/null +++ b/tools/vector-assets/src/main.rs @@ -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::() / 3., + vertices.iter().map(|vertex| vertex.position.y).sum::() / 3., + ); + let actual = painter.color(center)?; + for channel in 0..4 { + error = error.max( + (actual[channel] - colors.iter().map(|color| color[channel]).sum::() / 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 = + 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); + } +} diff --git a/tools/webos-runtime.mjs b/tools/webos-runtime.mjs new file mode 100644 index 0000000..206601b --- /dev/null +++ b/tools/webos-runtime.mjs @@ -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(); +} diff --git a/trampoline/README.md b/trampoline/README.md new file mode 100644 index 0000000..c6716ed --- /dev/null +++ b/trampoline/README.md @@ -0,0 +1,79 @@ +# luma-home + +Replaces the LG webOS Home screen with [Luma](https://github.com/ecklf/lg-luma) on webOS 5/6 TVs rooted via [Homebrew Channel](https://github.com/nicko88/LG-Homebrew). + +## How it works + +LG Home (`com.webos.app.home`) is a native QML app. This project bind-mounts a transparent web page over its directory so SAM sees a web-type Home. The web page immediately calls `WebOSServiceBridge` to launch Luma. A background watcher provides a fallback redirect via `luna-send` subscription. + +### Components + +| File | On TV at | Purpose | +|------|----------|---------| +| `overlay/appinfo.json` | `/media/developer/luma-home/overlay/` | Home manifest — web type, transparent, no splash | +| `overlay/index.html` | same | Minimal page that launches Luma via WebOSServiceBridge | +| `boot-hook.sh` | `/media/developer/luma-home/` | Homebrew Channel boot hook — mounts overlay, restarts SAM, launches Luma | +| `watcher.sh` | same | Background watcher — subscribes to foreground events, redirects Home→Luma | + +### Boot flow + +1. Homebrew Channel runs `boot-hook.sh` at cold boot +2. Bind-mount replaces `/usr/palm/applications/com.webos.app.home` (RAM only — reboot without the hook restores stock) +3. SAM restarts and reads the web manifest +4. Luma launches as the initial app +5. Watcher starts in the background + +### Home button flow + +1. User presses Home → system opens `com.webos.app.home` (our transparent web page) +2. `index.html` JS fires `WebOSServiceBridge` → launches Luma +3. Watcher catches the foreground change as backup → also launches Luma + +## Requirements + +- LG webOS TV rooted with Homebrew Channel (tested on webOS 6.5.3) +- Luma v2.1.0+ installed as `com.ecklf.luma` +- Telnet or SSH access to the TV + +## Install + +```sh +# Copy files to TV +scp -r overlay/ boot-hook.sh watcher.sh root@:/media/developer/luma-home/ + +# SSH into TV and install the boot hook +chmod +x /media/developer/luma-home/boot-hook.sh /media/developer/luma-home/watcher.sh +ln -sf /media/developer/luma-home/boot-hook.sh /var/lib/webosbrew/init.d/10-luma-home + +# Run it now (or reboot) +sh /media/developer/luma-home/boot-hook.sh +``` + +## Uninstall + +```sh +# Remove boot hook +rm /var/lib/webosbrew/init.d/10-luma-home + +# Kill watcher +kill $(cat /tmp/luma-watcher.pid 2>/dev/null) + +# Unmount overlay (or just reboot) +umount /usr/palm/applications/com.webos.app.home +``` + +## Known limitations + +- Brief visual transition when pressing Home (web page loads before Luma appears) +- `blockSystemApp` API (used by Luma's built-in Home integration for webOS 25/26) does not exist on webOS 5/6, so we use `systemctl restart sam` instead +- Native Home replacement (Luma binary as Home directly) does not work on webOS 6 — SAM handles native system apps differently + +## Future + +Fork Luma and patch `home.cjs` to support webOS 5/6 natively — replace the version check and `blockSystemApp` calls with the `systemctl restart sam` approach proven here. + +## Tested on + +- LG 65NANO813PA, webOS 6.5.3-47 (kisscurl-koli) +- Luma v2.1.0 +- Homebrew Channel v0.7.3 diff --git a/boot-hook.sh b/trampoline/boot-hook.sh similarity index 100% rename from boot-hook.sh rename to trampoline/boot-hook.sh diff --git a/overlay/appinfo.json b/trampoline/overlay/appinfo.json similarity index 100% rename from overlay/appinfo.json rename to trampoline/overlay/appinfo.json diff --git a/overlay/index.html b/trampoline/overlay/index.html similarity index 100% rename from overlay/index.html rename to trampoline/overlay/index.html diff --git a/watcher.sh b/trampoline/watcher.sh similarity index 100% rename from watcher.sh rename to trampoline/watcher.sh diff --git a/tv/autostart.sh b/tv/autostart.sh new file mode 100644 index 0000000..0e66578 --- /dev/null +++ b/tv/autostart.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Luma's optional cold-boot hook. Installed only when explicitly requested. +APP_DIR=/media/developer/apps/usr/palm/applications/com.ecklf.luma +HOOK=/var/lib/webosbrew/init.d/65-luma +case "${1:-}" in + enable) + [ "$(id -u)" = 0 ] || { printf '%s\n' 'Run as root.' >&2; exit 1; } + [ -f "$APP_DIR/appinfo.json" ] || { printf '%s\n' 'Install Luma first.' >&2; exit 1; } + mkdir -p /var/lib/webosbrew/init.d + if [ -e "$HOOK" ] || [ -L "$HOOK" ]; then + [ "$(readlink "$HOOK")" = "$APP_DIR/autostart.sh" ] || { printf '%s\n' '65-luma is already used by another file.' >&2; exit 1; } + fi + chmod 755 "$APP_DIR/autostart.sh" + ln -sf "$APP_DIR/autostart.sh" "$HOOK" + printf '%s\n' 'Luma will open after a full boot.' + exit 0 ;; + disable) + [ "$(id -u)" = 0 ] || exit 1 + if [ "$(readlink "$HOOK")" = "$APP_DIR/autostart.sh" ]; then rm -f "$HOOK"; fi + printf '%s\n' 'Luma boot launch disabled.' + exit 0 ;; +esac +# A lock lives for this boot only; do not launch twice when the hook is invoked again. +( + exec 9>/tmp/luma-boot.lock + flock -n 9 || exit 0 + [ -f /tmp/luma-boot-done ] && exit 0 + [ -f "$APP_DIR/appinfo.json" ] || exit 0 + sleep 25 + # -n prints the reply. -t is timing mode, not a firmware-specific flag. + RESULT="$(/usr/bin/luna-send -n 1 -w 5000 luna://com.webos.service.applicationmanager/launch '{"id":"com.ecklf.luma"}' 2>/dev/null)" + printf '%s\n' "$RESULT" + case "$RESULT" in *'"returnValue":true'*|*'"returnValue": true'*) touch /tmp/luma-boot-done ;; esac +) /tmp/luma-boot.log 2>&1 & diff --git a/tv/home.cjs b/tv/home.cjs new file mode 100644 index 0000000..0691ceb --- /dev/null +++ b/tv/home.cjs @@ -0,0 +1,435 @@ +// Root-only TV integration. Keep ES5 syntax for the TV's bundled Node runtime. +// The native executable remains in its installed package so its LS2 role, +// libraries, assets and preference identity continue to use that installation. +var fs = require('fs'); +var path = require('path'); +var execFileSync = require('child_process').execFileSync; +var spawnSync = require('child_process').spawnSync; +var APP = 'com.ecklf.luma'; +var HOME = 'com.webos.app.home'; +var STATE = '/var/lib/luma-home'; +var STAGE = STATE + '/app'; +var TARGET = '/usr/palm/applications/' + HOME; +var HOOK = '/var/lib/webosbrew/init.d/64-luma-home'; +var CONFIG = STATE + '/state.json'; + +function read(file) { + return fs.readFileSync(file, 'utf8'); +} + +function json(file) { + return JSON.parse(read(file)); +} + +function exists(file) { + try { + fs.lstatSync(file); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +function writeJson(file, value) { + fs.writeFileSync(file + '.new', JSON.stringify(value, null, 2) + '\n', { mode: 420 }); + fs.renameSync(file + '.new', file); +} + +function run(file, args) { + return String(execFileSync(file, args, { timeout: 8000, encoding: 'utf8' })); +} + +function pause() { + run('/bin/sleep', ['1']); +} + +function excerpt(output) { + return ( + String(output || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 480) || '' + ); +} + +function luna(method, payload) { + // -n prints one JSON reply to stdout. -t is timing mode and logs the + // payload to stderr instead, leaving timing summaries on stdout. + var result = spawnSync( + '/usr/bin/luna-send', + [ + '-n', + '1', + '-w', + '4000', + 'luna://com.webos.applicationManager/' + method, + JSON.stringify(payload) + ], + { + encoding: 'utf8', + timeout: 8000, + maxBuffer: 2 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + var stdout = String(result.stdout || '').trim(); + var diagnostics = '; stdout: ' + excerpt(stdout) + '; stderr: ' + excerpt(result.stderr); + if (result.error || result.status !== 0) { + throw new Error( + 'Luna ' + + method + + ' failed (' + + (result.error + ? result.error.message + : 'exit ' + result.status + ', signal ' + result.signal) + + ')' + + diagnostics + ); + } + if (!stdout) throw new Error('No Luna JSON reply from ' + method + diagnostics); + var reply; + try { + reply = JSON.parse(stdout); + } catch (error) { + throw new Error('Invalid Luna JSON reply from ' + method + ': ' + error.message + diagnostics); + } + if ( + !reply || + typeof reply !== 'object' || + Array.isArray(reply) || + typeof reply.returnValue !== 'boolean' + ) { + throw new Error('Invalid Luna response from ' + method + diagnostics); + } + return reply; +} + +function accepted(reply, action) { + if (!reply || reply.returnValue !== true) { + throw new Error(action + ': ' + JSON.stringify(reply)); + } + return reply; +} + +function mkdir(directory) { + if (!fs.existsSync(directory)) fs.mkdirSync(directory, 493); +} + +function mounted() { + if (!fs.existsSync(STAGE)) return false; + var a = fs.statSync(STAGE); + var b = fs.statSync(TARGET); + return a.dev === b.dev && a.ino === b.ino; +} + +function foreignMount() { + return ( + read('/proc/self/mountinfo') + .split('\n') + .some(function (line) { + var target = line.split(' ')[4] || ''; + return target === TARGET || target.indexOf(TARGET + '/') === 0; + }) && !mounted() + ); +} + +function releaseHome() { + var result; + for (var i = 0; i < 3; i++) { + try { + result = luna('releaseBlockSystemApp', { id: HOME }); + if (result.returnValue === true || /not.*blocked/i.test(result.errorText || '')) return; + } catch (error) { + result = { errorText: error.message }; + } + pause(); + } + throw new Error('Could not release Home: ' + JSON.stringify(result)); +} + +function reread(expected) { + var payload = { id: HOME }; + var schema = '/etc/palm/schemas/sam/applicationManager.blockSystemApp.schema'; + if (fs.existsSync(schema) && read(schema).indexOf('"forceBlockSystemApp"') !== -1) { + payload.forceBlockSystemApp = false; + } + // Always release, even if the block request times out after taking effect. + try { + var reply = luna('blockSystemApp', payload); + if (!/already\s*blocked/i.test(reply.errorText || '')) { + accepted(reply, 'Block Home for manifest refresh'); + } + } finally { + releaseHome(); + } + for (var i = 0; i < 15; i++) { + var info = accepted(luna('getAppInfo', { id: HOME }), 'Read Home manifest').appInfo; + if ( + info && + info.type === expected.type && + (typeof expected.main !== 'string' || + path.basename(info.main || '') === path.basename(expected.main)) && + (expected.main !== 'luma' || + (Array.isArray(info.appCategory) && info.appCategory.indexOf('home') !== -1)) + ) + return; + pause(); + } + throw new Error('SAM did not read the expected Home manifest'); +} + +function foreground() { + return accepted(luna('getForegroundAppInfo', {}), 'Read foreground app').appId; +} + +function normalRunning() { + return (accepted(luna('running', {}), 'Read running apps').running || []).some(function (entry) { + return entry.id === APP; + }); +} + +function closeNormal() { + if (!normalRunning()) return; + try { + // On TV, close's id identifies a process. Use the app-ID endpoint for APP. + accepted(luna('closeByAppId', { id: APP }), 'Close the normal Luma instance'); + } catch (error) { + // The app may have exited between the running query and the close request. + if (normalRunning()) throw error; + return; + } + for (var attempt = 0; attempt <= 5; attempt++) { + if (!normalRunning()) return; + if (attempt < 5) pause(); + } + throw new Error('Close Luma before enabling Home integration'); +} + +function stopHome(appDir) { + var stopped = []; + fs.readdirSync('/proc').forEach(function (pid) { + if (!/^\d+$/.test(pid)) return; + try { + var args = read('/proc/' + pid + '/cmdline').split('\0'); + var oldNative = args[0] === '/usr/bin/' + HOME; + var flutter = + path.basename(args[0]) === 'flutter-client' && + args.indexOf('-i') !== -1 && + args[args.indexOf('-i') + 1] === HOME; + var luma = + fs.readlinkSync('/proc/' + pid + '/exe').replace(/ \(deleted\)$/, '') === appDir + '/luma'; + var metadata = null; + try { + metadata = JSON.parse(args[1]); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + if (oldNative || flutter || (luma && metadata && metadata.nid === HOME)) { + process.kill(Number(pid), 'SIGTERM'); + stopped.push(pid); + } + } catch (error) { + // A process may exit while /proc is being read. Kernel threads have no exe. + if (error.code !== 'ENOENT' && error.code !== 'ESRCH') throw error; + } + }); + for (var i = 0; i < 5; i++) { + if ( + !stopped.some(function (pid) { + return fs.existsSync('/proc/' + pid); + }) + ) + return; + pause(); + } + throw new Error('The previous Home process has not stopped'); +} + +function launchHome() { + accepted(luna('launch', { id: HOME }), 'Open Home'); +} + +function hookOwned() { + try { + return fs.readlinkSync(HOOK) === STATE + '/home.sh'; + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'EINVAL') return false; + throw error; + } +} + +function restore(config) { + var wasHome = false; + try { + wasHome = foreground() === HOME; + } catch (error) { + console.error('Luma Home: ' + error.message + ', restoring the files first'); + } + if (foreignMount()) throw new Error('Another integration is mounted over LG Home'); + if (mounted()) { + try { + run('/bin/umount', [TARGET]); + } catch (error) { + // A running Home may still hold its working directory in the mount. + console.error('Luma Home: ' + error.message + ', detaching the mount'); + run('/bin/umount', ['-l', TARGET]); + } + if (mounted()) throw new Error('The Luma Home mount is still active'); + } + // Also finish a restore interrupted between unmounting and the rescan. + // Read the uncovered files, since a firmware update may have changed LG Home. + reread(json(TARGET + '/appinfo.json')); + stopHome(config.appDir); + if (wasHome) launchHome(); +} + +// The LG splash is a static file. Stage the splash of the owner's current background, so a cold +// boot into Luma Home opens on their wallpaper. Falls back to the package default on any error. +function stageSplash(appDir) { + var background = 'dunes'; + var candidates = [ + '/media/developer/' + APP + '/preferences.json', + '/media/developer/temp/' + APP + '/preferences.json', + '/media/internal/.' + APP + '/preferences.json' + ]; + // Match storage.rs: production native jails prefer internal storage. Reads + // skip absent files, preserving an existing destination before migration. + if (appDir.indexOf('/media/cryptofs/') === 0) candidates.unshift(candidates.pop()); + candidates.push(appDir + '/preferences.json'); + for (var i = 0; i < candidates.length; i++) { + try { + var saved = json(candidates[i]).background; + if (typeof saved === 'string' && /^[a-z0-9-]{1,32}$/.test(saved)) { + background = saved; + break; + } + } catch (error) { + if (error.code !== 'ENOENT') console.log('home: preferences unreadable: ' + error.message); + } + } + var source = appDir + '/splash/' + background + '.png'; + if (!fs.existsSync(source)) source = appDir + '/splash.png'; + try { + fs.writeFileSync(STAGE + '/splash.png.new', fs.readFileSync(source), { mode: 420 }); + fs.renameSync(STAGE + '/splash.png.new', STAGE + '/splash.png'); + } catch (error) { + console.log('home: splash not staged: ' + error.message); + } +} + +function apply(config, open) { + if (foreignMount()) throw new Error('Another integration is mounted over LG Home'); + if (!fs.existsSync(config.appDir + '/luma')) throw new Error('Luma is no longer installed'); + stageSplash(config.appDir); + var front = foreground(); + try { + if (!mounted()) run('/bin/mount', ['--bind', STAGE, TARGET]); + if (!mounted()) throw new Error('The Home bind mount did not take'); + reread(json(STAGE + '/appinfo.json')); + stopHome(config.appDir); + // Only replace the screen if Home is still in front. During boot the + // owner may have already selected an input or another app. + if (open || (front === HOME && foreground() === HOME)) launchHome(); + } catch (error) { + try { + restore(config); + } catch (recovery) { + throw new Error(error.message + '\nRestore also failed: ' + recovery.message); + } + throw error; + } +} + +function install(appDir) { + appDir = fs.realpathSync(appDir); + var app = json(appDir + '/appinfo.json'); + if (app.id !== APP || app.type !== 'native' || app.main !== 'luma') { + throw new Error('Enable this from an installed native Luma package'); + } + var release = read('/etc/starfish-release'); + if (!/release\s+(10|11)\./.test(release)) { + throw new Error('This Home integration currently targets webOS 25/26 (10.x/11.x)'); + } + if (foreignMount()) throw new Error('Another integration is mounted over LG Home'); + if (exists(HOOK) && !hookOwned()) throw new Error('64-luma-home is already in use'); + if (exists('/var/lib/webosbrew/init.d/65-luma')) { + throw new Error('Disable the standalone cold-boot hook with autostart.sh disable first'); + } + var previous = fs.existsSync(CONFIG) ? json(CONFIG) : null; + var stock = mounted() ? previous && previous.stock : json(TARGET + '/appinfo.json'); + if (!stock || stock.id !== HOME || stock.main === 'luma') { + throw new Error('Could not identify the original LG Home manifest'); + } + // Finish any normal tile instance before the Home identity starts using the + // same settings. A later tile launch redirects in the native executable. + closeNormal(); + mkdir(STAGE); + // Symlinks keep the executable at its installed path for the dynamic loader + // and LS2. They also follow ordinary in-place package updates. + ['luma', 'assets', 'lib', 'splash', app.icon, app.largeIcon].forEach(function (name) { + var link = STAGE + '/' + name; + if (exists(link + '.new')) fs.unlinkSync(link + '.new'); + fs.symlinkSync(appDir + '/' + name, link + '.new'); + fs.renameSync(link + '.new', link); + }); + app.id = HOME; + app.title = 'Home'; + app.visible = false; + app.appCategory = ['home']; + writeJson(STAGE + '/appinfo.json', app); + fs.writeFileSync(STAGE + '/.luma-home', APP + '\n', { mode: 420 }); + ['home.sh', 'home.cjs'].forEach(function (name) { + var target = STATE + '/' + name; + var data = fs.readFileSync(path.join(__dirname, name)); + fs.writeFileSync(target + '.new', data, { mode: name === 'home.sh' ? 493 : 420 }); + fs.renameSync(target + '.new', target); + }); + var config = { enabled: false, appDir: appDir, stock: stock }; + writeJson(CONFIG, config); + mkdir('/var/lib/webosbrew'); + mkdir('/var/lib/webosbrew/init.d'); + if (!hookOwned()) fs.symlinkSync(STATE + '/home.sh', HOOK); + apply(config, true); + config.enabled = true; + writeJson(CONFIG, config); + console.log('Luma is now Home. Home Auto Launch and Quick Start+ follow the TV settings.'); +} + +try { + var command = process.argv[2]; + if (command === 'enable') { + install(process.argv[3]); + } else if (command === 'status') { + var saved = fs.existsSync(CONFIG) ? json(CONFIG) : null; + console.log( + JSON.stringify( + { + enabled: !!(saved && saved.enabled), + mounted: mounted(), + bootHook: hookOwned(), + appDir: saved ? saved.appDir : null + }, + null, + 2 + ) + ); + } else if (command === 'apply' || command === 'disable' || command === 'restore') { + if (!fs.existsSync(CONFIG)) throw new Error('Luma Home has not been enabled'); + var config = json(CONFIG); + if (command === 'apply' && config.enabled) { + apply(config, false); + } else { + config.enabled = false; + writeJson(CONFIG, config); + restore(config); + if (hookOwned()) fs.unlinkSync(HOOK); + console.log('LG Home restored. Luma Home boot integration disabled.'); + } + } else { + throw new Error('Unknown Home integration command'); + } +} catch (error) { + console.error('Luma Home: ' + error.message); + process.exitCode = 1; +} diff --git a/tv/home.sh b/tv/home.sh new file mode 100644 index 0000000..bcaf54a --- /dev/null +++ b/tv/home.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Optional native Home replacement. The webosbrew hook has no .sh suffix. +STATE=/var/lib/luma-home +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) || exit 1 +COMMAND=${1:-boot} +case "$COMMAND" in + enable|disable|restore|status|apply|boot) ;; + *) printf '%s\n' 'Usage: home.sh enable [APP_DIR] | disable | restore | status' >&2; exit 1 ;; +esac +[ "$(id -u)" = 0 ] || { printf '%s\n' 'Run as root.' >&2; exit 1; } +[ -x /usr/bin/node ] || { printf '%s\n' 'The TV Node.js runtime is missing.' >&2; exit 1; } +if [ "$COMMAND" = enable ]; then + printf '%s\n' \ + 'Enabling Luma Home will:' \ + ' 1. Close any running normal Luma instance.' \ + " 2. Create $STATE with integration scripts, saved state and a staged native Home app linked to Luma." \ + " 3. Bind-mount $STATE/app over /usr/palm/applications/com.webos.app.home." \ + ' 4. Temporarily block and release Home so SAM rereads its manifest.' \ + ' 5. Stop the previous Home process and launch Luma as Home.' \ + ' 6. Install /var/lib/webosbrew/init.d/64-luma-home to reapply the mount at cold boot.' \ + '' \ + "LG Home's original files remain underneath the mount." \ + 'Home Auto Launch and Quick Start+ retain their current settings.' \ + "Restore LG Home with: sh $STATE/home.sh disable" \ + '' + while :; do + printf '%s' 'Apply these changes? [y/n]: ' + if ! IFS= read -r answer; then + printf '\n%s\n' 'Cancelled. No Home integration changes applied.' + exit 1 + fi + case "$answer" in + y|Y) break ;; + n|N) printf '%s\n' 'Cancelled. No Home integration changes applied.'; exit 0 ;; + *) printf '%s\n' 'Please answer y or n.' ;; + esac + done +fi +[ ! -L "$STATE" ] || exit 1 +if [ ! -d "$STATE" ]; then mkdir -m 755 "$STATE" || exit 1; fi +[ "$(stat -c %u "$STATE")" = 0 ] || exit 1 +if [ "$COMMAND" = boot ]; then + ( + sleep 5 + exec sh "$STATE/home.sh" apply + ) >"$STATE/boot.log" 2>&1 & + exit 0 +fi +exec 9>"$STATE/lock" +flock -n 9 || { printf '%s\n' 'Luma Home integration is already being changed.' >&2; exit 1; } +exec /usr/bin/node "$SCRIPT_DIR/home.cjs" "$COMMAND" "${2:-$SCRIPT_DIR}" diff --git a/tv/read-app-icon.cjs b/tv/read-app-icon.cjs new file mode 100644 index 0000000..fa4df6a --- /dev/null +++ b/tv/read-app-icon.cjs @@ -0,0 +1,100 @@ +// Embedded in the native executable and run only through Homebrew's elevated +// exec service when jailed file reads fail. Compatible with webOS's Node 8. +var fs = require('fs'); +var path = require('path'); +var source = JSON.parse(process.argv[1]); +var roots = [ + '/media/developer/apps/usr/palm/applications', + '/media/cryptofs/apps/usr/palm/applications', + '/usr/palm/applications' +]; +var remaining = 512 * 1024; +var icons = []; +var seen = Object.create(null); + +function read(file, limit) { + var fd; + try { + fd = fs.openSync(file, 'r'); + var stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size > limit) return null; + var bytes = Buffer.alloc(stat.size); + var offset = 0; + while (offset < bytes.length) { + var count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset); + if (!count) return null; + offset += count; + } + return bytes; + } catch (error) { + if (error.code !== 'ENOENT' && error.code !== 'EACCES') console.error(error.message); + return null; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +if ( + typeof source.id !== 'string' || + source.id.length > 128 || + !/^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$/.test(source.id) +) { + throw new Error('Invalid application ID'); +} +var directories = roots.map(function (root) { + return path.join(root, source.id); +}); +if ( + typeof source.directory === 'string' && + roots.indexOf(path.dirname(source.directory)) >= 0 && + source.directory.split('/').indexOf('..') < 0 +) { + if (directories.indexOf(source.directory) >= 0) + directories.splice(directories.indexOf(source.directory), 1); + directories.unshift(source.directory); +} +directories.forEach(function (directory) { + var manifest = {}; + var bytes = read(path.join(directory, 'appinfo.json'), 64 * 1024); + if (bytes) { + try { + manifest = JSON.parse(bytes.toString('utf8')); + if (!manifest || manifest.id !== source.id) manifest = {}; + } catch (error) { + manifest = {}; + console.error('Invalid app manifest: ' + error.message); + } + } + [source.large_icon, manifest.largeIcon, source.icon, manifest.icon, 'icon.png'].forEach( + function (icon) { + if ( + typeof icon !== 'string' || + !icon || + icon.length > 4096 || + icon.split('').some(function (character) { + var code = character.charCodeAt(0); + return code < 32 || code === 127; + }) + ) + return; + icon = icon.replace(/^file:\/\//, ''); + if (icon.indexOf('://') >= 0) return; + if (icon.split('/').indexOf('..') >= 0) return; + var file = path.resolve(directory, icon); + if (['.png', '.jpg', '.jpeg', '.webp'].indexOf(path.extname(file).toLowerCase()) < 0) return; + if ( + !roots.some(function (root) { + return file.indexOf(root + '/') === 0; + }) || + seen[file] + ) + return; + seen[file] = true; + var data = read(file, remaining); + if (!data || !data.length) return; + remaining -= data.length; + icons.push(data.toString('hex')); + } + ); +}); +console.log(JSON.stringify({ returnValue: true, icons: icons })); diff --git a/tv/sync-splash.cjs b/tv/sync-splash.cjs new file mode 100644 index 0000000..831db08 --- /dev/null +++ b/tv/sync-splash.cjs @@ -0,0 +1,57 @@ +// Embedded in the native executable and run on its platform worker through +// Homebrew's elevated exec service. Keep ES5 syntax for webOS's Node 8. +var fs = require('fs'); +var path = require('path'); +var selected = JSON.parse(process.argv[1]); +var APP = 'com.ecklf.luma'; +var STATE = '/var/lib/luma-home'; +var roots = [ + '/media/developer/apps/usr/palm/applications', + '/media/cryptofs/apps/usr/palm/applications', + '/usr/palm/applications' +]; + +if ( + typeof selected.directory !== 'string' || + !roots.some(function (root) { + return selected.directory === root + '/' + APP; + }) +) { + throw new Error('Invalid Luma installation directory'); +} +if (typeof selected.background !== 'string' || !/^[a-z0-9-]{1,32}$/.test(selected.background)) { + throw new Error('Invalid splash background'); +} +var appDir = selected.directory; +var app = JSON.parse(fs.readFileSync(appDir + '/appinfo.json', 'utf8')); +if (app.id !== APP || app.type !== 'native' || app.main !== 'luma') { + throw new Error('Splash updates require an installed native Luma package'); +} +// Read the selection already loaded by the native app, rather than trying to +// rediscover its preferences from outside the jail. Never copy the default over +// a working splash if an update is missing the selected image. +var image = fs.readFileSync(path.join(appDir, 'splash', selected.background + '.png')); + +function replace(file) { + try { + if (fs.readFileSync(file).equals(image)) return; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + fs.writeFileSync(file + '.new', image, { mode: 420 }); + fs.renameSync(file + '.new', file); +} + +// SAM's normal tile uses the installed manifest's splash.png. Updating only +// the staged Home copy leaves every normal launch on the package default. +replace(appDir + '/splash.png'); +var config = null; +try { + config = JSON.parse(fs.readFileSync(STATE + '/state.json', 'utf8')); +} catch (error) { + if (error.code !== 'ENOENT') throw error; +} +if (config && config.enabled && config.appDir === appDir) { + replace(STATE + '/app/splash.png'); +} +console.log(JSON.stringify({ returnValue: true, background: selected.background })); diff --git a/vector-geometry/Cargo.toml b/vector-geometry/Cargo.toml new file mode 100644 index 0000000..f0b8fa8 --- /dev/null +++ b/vector-geometry/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "luma-vector-geometry" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +publish = false +license = "MIT" + +[dependencies] +lyon_tessellation = "1.0" diff --git a/vector-geometry/src/lib.rs b/vector-geometry/src/lib.rs new file mode 100644 index 0000000..d191731 --- /dev/null +++ b/vector-geometry/src/lib.rs @@ -0,0 +1,181 @@ +//! Shared polygon tessellation and drawable-aware boundary coverage. +pub use lyon_tessellation; +use lyon_tessellation::{ + BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers, + math::{Point, point}, + path::{Event, Path, iterator::PathIterator}, +}; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug)] +pub struct Vertex { + pub position: Point, + pub offset: [f32; 2], + pub coverage: f32, +} + +impl Vertex { + pub 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, + } + } +} + +/// Fill a path and add a one-physical-pixel boundary fringe. The caller scales +/// positions in its shader; offsets remain in physical pixels at every density. +pub fn tessellate(path: &Path, tolerance: f32, rule: FillRule) -> Result, String> { + let outline: Vec<_> = path + .iter() + .flattened(tolerance) + .filter_map(|event| match event { + Event::Line { from, to } => Some((from, to)), + Event::End { + last, + first, + close: true, + } => Some((last, first)), + _ => None, + }) + .collect(); + let contains = |p: Point| { + let mut winding = 0i32; + for &(a, b) in &outline { + if (a.y <= p.y && b.y > p.y) || (b.y <= p.y && a.y > p.y) { + let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y); + if x > p.x { + winding += if b.y > a.y { 1 } else { -1 }; + } + } + } + match rule { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding % 2 != 0, + } + }; + let mut geometry: VertexBuffers = VertexBuffers::new(); + FillTessellator::new() + .tessellate_path( + path, + &FillOptions::default() + .with_tolerance(tolerance) + .with_fill_rule(rule), + &mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| vertex.position()), + ) + .map_err(|e| format!("Path tessellation failed: {e:?}"))?; + let mut unique = BTreeMap::new(); + let mut points = Vec::new(); + let mapping: Vec = geometry + .vertices + .iter() + .map(|p| { + let key = ( + (if p.x == 0. { 0. } else { p.x }).to_bits(), + (if p.y == 0. { 0. } else { p.y }).to_bits(), + ); + *unique.entry(key).or_insert_with(|| { + points.push(*p); + points.len() - 1 + }) + }) + .collect(); + let mut triangles = Vec::new(); + let mut edges: BTreeMap<(usize, usize), (usize, usize, usize)> = BTreeMap::new(); + for triangle in geometry.indices.chunks_exact(3) { + let [a, mut b, mut c] = [ + mapping[triangle[0] as usize], + mapping[triangle[1] as usize], + mapping[triangle[2] as usize], + ]; + if triangles.len() > 300_000 { + return Err("Path exceeds its geometry budget".into()); + } + let area = (points[b] - points[a]).cross(points[c] - points[a]); + // Positions may be in font em units as well as SVG canvas units. + if area.abs() < 1e-12 { + continue; + } + if area < 0. { + std::mem::swap(&mut b, &mut c); + } + triangles.push([a, b, c]); + for (a, b) in [(a, b), (b, c), (c, a)] { + edges + .entry((a.min(b), a.max(b))) + .and_modify(|edge| edge.2 += 1) + .or_insert((a, b, 1)); + } + } + let boundary: Vec<_> = edges + .values() + .filter(|edge| edge.2 == 1) + .filter(|&&(a, b, _)| { + // Do not add fringes to unpaired interior T-junctions. + let d = points[b] - points[a]; + let length = d.length(); + if length == 0. { + return false; + } + let midpoint = points[a] + d * 0.5; + let probe = + point(d.y / length, -d.x / length).to_vector() * (tolerance * 0.25).max(0.0000001); + contains(midpoint + probe) != contains(midpoint - probe) + }) + .map(|&(a, b, _)| (a, b)) + .collect(); + let mut normals = vec![[0., 0.]; points.len()]; + let mut first_normal = normals.clone(); + for &(a, b) in &boundary { + let d = points[b] - points[a]; + let length = d.length(); + if length == 0. { + continue; + } + let n = [d.y / length, -d.x / length]; + for index in [a, b] { + normals[index][0] += n[0]; + normals[index][1] += n[1]; + first_normal[index] = n; + } + } + for (index, normal) in normals.iter_mut().enumerate() { + let length = normal[0].hypot(normal[1]); + if length > 1e-6 { + let unit = [normal[0] / length, normal[1] / length]; + let miter = 1. + / (unit[0] * first_normal[index][0] + unit[1] * first_normal[index][1]).max(0.25); + *normal = [unit[0] * miter, unit[1] * miter]; + } + } + let vertex = |index: usize, outside: bool| Vertex { + position: points[index], + offset: [ + normals[index][0] * if outside { 0.5 } else { -0.5 }, + normals[index][1] * if outside { 0.5 } else { -0.5 }, + ], + coverage: if outside { 0. } else { 1. }, + }; + let mut vertices = Vec::with_capacity(triangles.len() * 3 + boundary.len() * 6); + for [a, b, c] in triangles { + vertices.extend([vertex(a, false), vertex(b, false), vertex(c, false)]); + } + for (a, b) in boundary { + vertices.extend([ + vertex(a, false), + vertex(a, true), + vertex(b, false), + vertex(b, false), + vertex(a, true), + vertex(b, true), + ]); + } + if vertices.len() > 1_000_000 { + return Err("Path exceeds its geometry budget".into()); + } + Ok(vertices) +}