fix(desktop): render icons with core runtime dependencies

This commit is contained in:
ethernet
2026-09-24 01:30:15 -04:00
parent 156efd4ba2
commit dcd2bca06a
34 changed files with 164 additions and 273 deletions

View File

@@ -69,9 +69,11 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Set up the locked site toolchain
id: pm
uses: ./.github/actions/setup-pm
with:
toolchain: all
extras: '[]'
node-cache-dependency-path: website/package-lock.json
- name: Install ruamel.yaml for skill extraction
@@ -182,6 +184,10 @@ jobs:
- name: Build Docusaurus
run: npm run build
working-directory: website
env:
# prebuild renders icons on a Hermes runtime interpreter; the ci-tools
# step above re-exports HERMES_PYTHON to a tools-only environment.
HERMES_PYTHON: ${{ steps.pm.outputs.python-path }}
- name: Stage deployment
run: |

View File

@@ -60,19 +60,20 @@ jobs:
pyproject.toml
uv.lock
- name: Set up Python 3.11
- name: Set up Python 3.14
uses: ./.github/actions/retry
with:
command: uv python install 3.11
command: uv python install 3.14
- name: Install Python dependencies
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
command: uv sync --locked --python 3.14 --extra all --extra dev
- name: Build the desktop app
working-directory: apps/desktop
run: npm run build
# The generator uses the runtime's Pillow + resvg-py, not a build-only venv.
run: HERMES_PYTHON="$GITHUB_WORKSPACE/.venv/bin/python" npm run build
- name: Run the core suite under xvfb
working-directory: apps/desktop

View File

@@ -28,9 +28,11 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The generator runs on a Hermes runtime interpreter; [] exports one as HERMES_PYTHON.
- uses: ./.github/actions/setup-pm
with:
toolchain: all
extras: '[]'
cache-python: true
prune-python-cache: true

View File

@@ -39,10 +39,12 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# tauri::generate_context! embeds the app icons, which are generated,
# not committed.
# not committed. The generator runs on a Hermes runtime interpreter; []
# exports one as HERMES_PYTHON.
- uses: ./.github/actions/setup-pm
with:
toolchain: all
extras: '[]'
cache-python: true
prune-python-cache: true

View File

@@ -195,9 +195,9 @@ Use a Node/npm version accepted by the corresponding `package.json` engines.
Native desktop dependencies can also require the platform build toolchain.
Logos and icons are generated from `assets/nous-girl-*.svg` and
`assets/backgrounds/`. `node scripts/generate-icons.mjs` uses the locked,
isolated `icon-build` dependency group. Do not commit generated PNG/ICO/ICNS
outputs or add icon renderers to production dependencies.
`assets/backgrounds/`. `node scripts/generate-icons.mjs` renders them with the
Hermes runtime Python (`HERMES_PYTHON`, else `python` on PATH): Pillow and
resvg-py are core dependencies. Do not commit generated PNG/ICO/ICNS outputs.
### Run tests

View File

@@ -222,50 +222,7 @@ ENV HERMES_PYTHON=/usr/local/bin/python3
# native extensions must use the compiler installed in this image.
ENV CC=gcc CXX=g++
# Frontend dependencies never enter the runtime layers.
FROM runtime_base AS frontend_build
COPY package.json package-lock.json ./
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
COPY apps/shared/ apps/shared/
COPY scripts/build/node-deps.mjs scripts/build/node-deps.mjs
ENV npm_config_install_links=false
RUN node scripts/build/node-deps.mjs --source /opt/hermes --workspace ui-tui --workspace web
COPY pyproject.toml uv.lock ./
COPY web/ web/
COPY ui-tui/ ui-tui/
COPY scripts/build/*.mjs scripts/build/
COPY scripts/build/icon_environment.py scripts/build/icon_environment.py
COPY scripts/generate-icons.mjs scripts/generate_icons.py scripts/
COPY assets/ assets/
RUN node scripts/generate-icons.mjs --source /opt/hermes --out /tmp/hermes-icons && \
node scripts/build/tui.mjs --source /opt/hermes --out /opt/products/tui && \
node scripts/build/web.mjs --source /opt/hermes --icons /tmp/hermes-icons --out /opt/products/web
FROM runtime_base AS runtime
# Standalone TypeScript linting is a runtime feature; Vite/esbuild are not.
COPY --from=frontend_build /opt/hermes/node_modules/typescript /opt/hermes/node_modules/typescript
RUN mkdir -p /opt/hermes/node_modules/.bin && \
ln -s ../typescript/bin/tsc /opt/hermes/node_modules/.bin/tsc
# ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
# The photon plugin's Node sidecar needs its own node_modules
# (spectrum-ts). The install tree is immutable at runtime, so a lazy
# `npm ci` on first connect would hit EROFS — bake the deps here instead
# (deterministic installs, NS-559). The patch script is copied alongside
# the manifests because package.json's postinstall runs it, which also
# means the spectrum-ts patch is applied at build time. Layer-cached:
# only re-runs when the sidecar manifests/patch change.
COPY plugins/platforms/photon/sidecar/package.json \
plugins/platforms/photon/sidecar/package-lock.json \
plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \
plugins/platforms/photon/sidecar/
RUN cd plugins/platforms/photon/sidecar && \
npm ci --no-audit --fetch-retries=5 && \
npm cache clean --force
FROM runtime_base AS python_deps
# ---------- Layer-cached Python dependency install ----------
# Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel
# download + native-extension compile layer is cached unless those inputs
@@ -320,6 +277,57 @@ RUN python3 -m pm.build_env --source /opt/hermes --python /usr/local/bin/python3
--extra all --extra messaging --extra otlp --extra anthropic --extra bedrock \
--extra azure-identity --extra hindsight --extra matrix --extra google-chat
# Icons render on the runtime environment: Pillow and resvg-py are core
# dependencies. A stage of its own so the frontend stage keeps building its
# Node dependencies in parallel with the Python ones.
FROM python_deps AS icons
COPY scripts/generate_icons.py scripts/
COPY assets/ assets/
RUN /opt/hermes/.venv/bin/python -I scripts/generate_icons.py --source /opt/hermes --out /tmp/hermes-icons
# Frontend dependencies never enter the runtime layers.
FROM runtime_base AS frontend_build
COPY package.json package-lock.json ./
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
COPY apps/shared/ apps/shared/
COPY scripts/build/node-deps.mjs scripts/build/node-deps.mjs
ENV npm_config_install_links=false
RUN node scripts/build/node-deps.mjs --source /opt/hermes --workspace ui-tui --workspace web
COPY pyproject.toml uv.lock ./
COPY web/ web/
COPY ui-tui/ ui-tui/
COPY scripts/build/*.mjs scripts/build/
COPY scripts/generate-icons.mjs scripts/generate_icons.py scripts/
COPY assets/ assets/
COPY --from=icons /tmp/hermes-icons /tmp/hermes-icons
RUN node scripts/build/tui.mjs --source /opt/hermes --out /opt/products/tui && \
node scripts/build/web.mjs --source /opt/hermes --icons /tmp/hermes-icons --out /opt/products/web
FROM python_deps AS runtime
# Standalone TypeScript linting is a runtime feature; Vite/esbuild are not.
COPY --from=frontend_build /opt/hermes/node_modules/typescript /opt/hermes/node_modules/typescript
RUN mkdir -p /opt/hermes/node_modules/.bin && \
ln -s ../typescript/bin/tsc /opt/hermes/node_modules/.bin/tsc
# ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
# The photon plugin's Node sidecar needs its own node_modules
# (spectrum-ts). The install tree is immutable at runtime, so a lazy
# `npm ci` on first connect would hit EROFS — bake the deps here instead
# (deterministic installs, NS-559). The patch script is copied alongside
# the manifests because package.json's postinstall runs it, which also
# means the spectrum-ts patch is applied at build time. Layer-cached:
# only re-runs when the sidecar manifests/patch change.
COPY plugins/platforms/photon/sidecar/package.json \
plugins/platforms/photon/sidecar/package-lock.json \
plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \
plugins/platforms/photon/sidecar/
RUN cd plugins/platforms/photon/sidecar && \
npm ci --no-audit --fetch-retries=5 && \
npm cache clean --force
# Shared product outputs are independent of application dependency assembly.
COPY --from=frontend_build /opt/products/tui /opt/hermes/ui-tui
COPY --from=frontend_build /opt/products/web /opt/hermes/hermes_cli/web_dist

View File

@@ -339,9 +339,9 @@ For an ordinary package of that desktop build, use the workspace's
replace the complete tagged build described above.
Icons are generated from `assets/nous-girl-*.svg` and `assets/backgrounds/`.
`node scripts/generate-icons.mjs` uses the locked, isolated `icon-build` group.
Generated PNG/ICO/ICNS files are not source assets. Build-only renderers do not
belong in production payload dependencies.
`node scripts/generate-icons.mjs` renders them with the Hermes runtime Python
(`HERMES_PYTHON`, else `python` on PATH); Pillow and resvg-py are core
dependencies. Generated PNG/ICO/ICNS files are not source assets.
[Stable release admission](../../docs/stable-releases.md) requires the full
pipeline, not just successful packaging. Native signed-package update tests

View File

@@ -6,13 +6,13 @@ import { parseArgs } from 'node:util'
import { generateIcons } from '../../../scripts/generate-icons.mjs'
import { isMain, repoRoot } from '../../../scripts/build/frontend-common.mjs'
export function buildSourceDesktop({ source = repoRoot, icons, onDemand = false, run = execFileSync, generate = generateIcons } = {}) {
export function buildSourceDesktop({ source = repoRoot, icons, run = execFileSync, generate = generateIcons } = {}) {
source = resolve(source)
const app = join(source, 'apps/desktop')
const step = (script, args = []) => run(process.execPath, [join(source, script), ...args], { cwd: app, stdio: 'inherit' })
step('apps/desktop/scripts/assert-root-install.mjs')
if (!icons) {
if (generate(['--source', source, '--out', source, ...(onDemand ? ['--on-demand'] : [])]) !== 0) throw new Error('Icon preparation failed')
if (generate(['--source', source, '--out', source]) !== 0) throw new Error('Icon preparation failed')
icons = source
}
icons = resolve(icons)
@@ -28,6 +28,6 @@ export function buildSourceDesktop({ source = repoRoot, icons, onDemand = false,
}
if (isMain(import.meta.url)) {
const { values } = parseArgs({ options: { icons: { type: 'string' }, 'on-demand': { type: 'boolean' } } })
buildSourceDesktop({ ...values, onDemand: values['on-demand'] })
const { values } = parseArgs({ options: { icons: { type: 'string' } } })
buildSourceDesktop(values)
}

View File

@@ -1177,7 +1177,7 @@ def _promote_staged_desktop_app(
def build_prepared_desktop(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict,
icons: Path | None = None, explicit: bool = False) -> Optional[Path]:
icons: Path | None = None) -> Optional[Path]:
"""Build prepared desktop sources, then publish the verified staged app."""
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
@@ -1185,7 +1185,7 @@ def build_prepared_desktop(desktop_dir: Path, *, source_mode: bool, npm: str, en
if _force_adhoc_macos_signing(build_env, source_mode=source_mode):
print(" → No Developer ID configured; ad-hoc signing this local rebuild "
"(CSC_IDENTITY_AUTO_DISCOVERY=false)")
build_args = (["--icons", str(icons)] if icons else []) + ([] if explicit else ["--on-demand"])
build_args = ["--icons", str(icons)] if icons else []
build_cmd = [npm, "run", "build", "--", *build_args]
staging_dir = None if source_mode else _desktop_staging_dir(desktop_dir)
if staging_dir is not None:
@@ -1357,8 +1357,7 @@ def cmd_gui(args: argparse.Namespace):
elif needs_build:
prepare_source_dependencies(PROJECT_ROOT, ("ui-tui", "web", "apps/desktop"), env=build_env,
explicit=force_build or getattr(args, "build_only", False))
built = build_prepared_desktop(desktop_dir, source_mode=source_mode, npm=npm, env=build_env,
explicit=force_build or getattr(args, "build_only", False))
built = build_prepared_desktop(desktop_dir, source_mode=source_mode, npm=npm, env=build_env)
if not source_mode:
packaged_executable = built
else:

View File

@@ -71,11 +71,10 @@ def build_source_tui(project_root: Path, *, env: dict) -> None:
run_source_script(project_root, "scripts/build/tui.mjs", env=env)
def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None,
explicit: bool = False) -> None:
def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None) -> None:
if icons is None:
icons = project_root
run_source_script(project_root, "scripts/generate-icons.mjs", *(() if explicit else ("--on-demand",)), env=env)
run_source_script(project_root, "scripts/generate-icons.mjs", env=env)
run_source_script(project_root, "scripts/build/web.mjs", "--source", str(project_root),
"--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env)
@@ -107,14 +106,14 @@ def build_update_products(project_root: Path, *, desktop: bool) -> None:
build_source_tui(project_root, env=env)
if "web" in frontends:
publish_stage("Building the web UI")
build_source_web(project_root, env=env, explicit=True)
build_source_web(project_root, env=env)
if desktop:
from hermes_cli.main_desktop import _install_rebuilt_desktop_app, build_prepared_desktop
publish_stage("Building the desktop app")
build_prepared_desktop(
project_root / "apps/desktop", source_mode=False,
npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root, explicit=True,
npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root,
)
# A current release/ can still sit beside a stale installed copy (an earlier
# update rebuilt but never installed); healing must not wait for the next build.

View File

@@ -92,8 +92,9 @@ let
inherit uv2nix pyproject-nix pyproject-build-systems;
};
# Icons render on the runtime venv: Pillow and resvg-py are core dependencies.
generatedIcons = callPackage ./icons.nix {
inherit (mkHermesVenv [ ]) iconBuildVenv;
inherit (mkHermesVenv [ ]) venv;
};
hermesNpmLib = callPackage ./lib.nix {

View File

@@ -1,5 +1,5 @@
# Run the shared generator offline; generated assets are not tracked in git.
{ lib, runCommand, iconBuildVenv }:
{ lib, runCommand, venv }:
let
src = lib.fileset.toSource {
root = ./..;
@@ -9,7 +9,7 @@ let
];
};
in
runCommand "hermes-icons" { nativeBuildInputs = [ iconBuildVenv ]; } ''
runCommand "hermes-icons" { nativeBuildInputs = [ venv ]; } ''
python ${src}/scripts/generate_icons.py --source ${src} --out $out
python ${src}/scripts/generate_icons.py --source ${src} --out $out --check
''

View File

@@ -165,11 +165,6 @@ in
{
inherit python;
# Equivalent to uv's --only-group: use the lock-derived dependency spec,
# without installing Hermes or its runtime dependencies in the build env.
iconBuildVenv = pythonSet.mkVirtualEnv "hermes-icon-build-env"
pythonSet.hermes-agent.dependency-groups.icon-build;
venv = pythonSet.mkVirtualEnv "hermes-agent-env" {
hermes-agent = dependency-groups;
};

View File

@@ -17,7 +17,6 @@ def main(argv: Sequence[str] | None = None) -> int:
parser.add_argument("--cache", type=Path)
parser.add_argument("--extra", dest="extras", action="append", default=[])
parser.add_argument("--group", dest="groups", action="append", default=[])
parser.add_argument("--only-groups", action="store_true", help="install selected groups without the application")
parser.add_argument("--all-extras", action="store_true")
parser.add_argument("--no-install-project", action="store_true")
parser.add_argument("--resolve", action="store_true", help="resolve the source lock before building")
@@ -96,7 +95,7 @@ def main(argv: Sequence[str] | None = None) -> int:
else:
executable = pm.build_environment(
source=args.source, out=args.out, python=args.python, cache=args.cache,
extras=args.extras, groups=args.groups, only_groups=args.only_groups, all_extras=args.all_extras,
extras=args.extras, groups=args.groups, all_extras=args.all_extras,
no_install_project=args.no_install_project, frozen=not args.resolve,
sealed=args.sealed, offline=args.offline, explicit=True,
)

View File

@@ -238,7 +238,7 @@ def _python_operation(operation: str, arguments: dict):
def build_environment(
*, source: Path, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None,
extras: Sequence[str] = (), groups: Sequence[str] = (), only_groups: bool = False,
extras: Sequence[str] = (), groups: Sequence[str] = (),
all_extras: bool = False, no_install_project: bool = False,
frozen: bool = True, sealed: bool = False, offline: bool = False,
explicit: bool = False, timeout: int = 1800,
@@ -246,7 +246,7 @@ def build_environment(
"""Build a validated Python environment without exposing install machinery."""
return Path(_python_operation("build_environment", {
"source": Path(source), "out": Path(out), "python": python, "cache": cache,
"env": dict(env) if env is not None else None, "extras": list(extras), "groups": list(groups), "only_groups": only_groups,
"env": dict(env) if env is not None else None, "extras": list(extras), "groups": list(groups),
"all_extras": all_extras, "no_install_project": no_install_project,
"frozen": frozen, "sealed": sealed, "offline": offline,
"explicit": explicit, "timeout": timeout,

View File

@@ -306,14 +306,12 @@ class PythonEnvironment:
def sync(self, source: Path, *, extras: Sequence[str] = (), groups: Sequence[str] = (),
timeout: int = 1800, frozen: bool = True, all_extras: bool = False,
no_install_project: bool = False, locked: bool = False,
no_default_groups: bool = False, only_groups: bool = False) -> None:
no_default_groups: bool = False) -> None:
"""Install the root and every member; resolve only in a writable workspace.
``frozen=False`` is reserved for the caller-owned generated workspace,
never the original project's lock. Seed/replay policy belongs to PM.
"""
if only_groups and (not groups or extras or all_extras):
raise ValueError("group-only builds require groups and cannot select extras")
if not frozen:
self.lock(source, timeout=timeout)
# Locking members alone is insufficient: plain sync only installs root deps.
@@ -333,7 +331,7 @@ class PythonEnvironment:
for extra in sorted(set(extras)):
command += ["--extra", extra]
for group in sorted(set(groups)):
command += ["--only-group" if only_groups else "--group", group]
command += ["--group", group]
result = self._run(command, cwd=source, timeout=timeout)
if result.returncode:
raise classify_uv_failure("sync", result.returncode, result.stderr or result.stdout)

View File

@@ -28,7 +28,7 @@ def _require_install_allowed(explicit: bool) -> None:
def build_environment(
*, source: Path, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None,
extras: Sequence[str] = (), groups: Sequence[str] = (), only_groups: bool = False,
extras: Sequence[str] = (), groups: Sequence[str] = (),
all_extras: bool = False, no_install_project: bool = False,
frozen: bool = True, sealed: bool = False, offline: bool = False,
explicit: bool = False, timeout: int = 1800,
@@ -60,7 +60,7 @@ def build_environment(
offline=offline, explicit=explicit, output=sys.stderr,
)
with _fresh_build(environment, sealed=sealed):
environment.sync(source, extras=extras, groups=groups, only_groups=only_groups, all_extras=all_extras,
environment.sync(source, extras=extras, groups=groups, all_extras=all_extras,
no_install_project=no_install_project, frozen=frozen, timeout=timeout)
return environment.executable

View File

@@ -172,6 +172,12 @@ dependencies = [
# Ships prebuilt wheels bundling libheif for the common platforms (no system
# libs needed), so it's safe in the base install alongside Pillow.
"pillow-heif>=1.4.0,<2; python_version >= '3.14'",
# SVG rendering for the icon generator (scripts/generate_icons.py). Source
# builds render icons with the runtime interpreter (`hermes desktop`,
# `hermes update`), so a source install carries its renderer instead of
# preparing a separate environment at launch. abi3 wheels for every
# shipped platform, including Windows ARM64 and Android.
"resvg-py==0.4.0; python_version >= '3.14'",
# Windows log rotation. Stdlib ``RotatingFileHandler.doRollover()`` uses
# ``os.rename()`` which fails with ``PermissionError [WinError 32]`` on
# Windows whenever any other process holds an append-mode handle on
@@ -533,8 +539,6 @@ dev = [
"ruff==0.15.10",
"setuptools==83.0.0",
] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83)
# --only-group supplies Pillow without installing the Hermes application.
icon-build = ["Pillow==12.3.0", "resvg-py==0.4.0"]
# Native launcher acceptance is required in test environments, not payloads.
test = ["distlib==0.4.3; sys_platform == 'win32'"]

View File

@@ -169,7 +169,8 @@ The web builder reads `web/public/` beneath it and requires `favicon.ico`.
The desktop builder reads `apps/desktop/public/` beneath it and requires
`apple-touch-icon.png`.
Run the generator with a prepared `icon-build` Python environment:
Run the generator with a Hermes runtime Python (Pillow and resvg-py are core
dependencies):
```sh
python scripts/generate_icons.py --source /work/source --out /work/products/icons
@@ -183,18 +184,16 @@ output image properties. It does not compare output bytes with regenerated bytes
The generator writes targets directly, not through the frontend
publication helper.
The convenience wrapper prepares the locked environment and can access the
network:
The convenience wrapper runs the same generator on `HERMES_PYTHON` (else
`python` on PATH) and never installs anything:
```sh
node scripts/generate-icons.mjs --source /work/source --out /work/products/icons
```
It uses the isolated `icon-build` dependency group and
`SOURCE/.cache/icon-build`. Both icon commands accept `--check`. The convenience
wrapper also accepts `--on-demand`, which preserves PM's lazy-install admission
policy rather than treating an automatic stale build as explicit installation. Without
explicit paths, they use the source checkout as the output root.
Both icon commands accept `--check`. Without explicit paths, they use the
source checkout as the output root. Builders without a runtime environment
prepare one with `scripts/build/icon_environment.py`.
The desktop native tree contains prepared packages, including `node-pty` with
its compiled binding. macOS also requires `get-windows/main`. The provider owns

View File

@@ -1,46 +1,24 @@
"""Prepare the locked icon-only environment through PM, then run the generator."""
"""Prepare an interpreter that can run the icon generator without a Hermes install.
Pillow and resvg-py are core runtime dependencies, so builders that have no
Hermes runtime environment (desktop bundles, product staging) render icons on
the locked runtime dependencies alone, without installing the application.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import subprocess
import sys
from tempfile import TemporaryDirectory
# The Node wrapper can run this file from a separately prepared source tree.
# Builders import this file from a separately prepared source tree.
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import pm
def prepare_icon_environment(source: Path, out: Path, cache: Path, *, explicit: bool = True) -> Path:
"""Prepare a fresh locked build-only interpreter without generating assets."""
def prepare_icon_environment(source: Path, out: Path, cache: Path | None) -> Path:
"""Prepare a fresh interpreter with the locked runtime dependencies only."""
return pm.build_environment(
source=source.resolve(), out=out.resolve(), cache=cache.resolve(),
groups=["icon-build"], only_groups=True, explicit=explicit,
source=source.resolve(), out=out.resolve(), cache=cache.resolve() if cache else None,
no_install_project=True, explicit=True,
)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
parser.add_argument("--source", type=Path, default=ROOT)
parser.add_argument("--on-demand", action="store_true")
args, _ = parser.parse_known_args(argv)
if args.on_demand:
argv.remove("--on-demand")
source = args.source.resolve()
with TemporaryDirectory(prefix="hermes-icon-build-") as temporary:
python = prepare_icon_environment(
source, Path(temporary) / "venv", source / ".cache/icon-build",
explicit=not args.on_demand,
)
return subprocess.run(
[str(python), "-I", str(ROOT / "scripts/generate_icons.py"), *argv],
cwd=source,
).returncode
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
@@ -37,7 +38,11 @@ def main(argv=None) -> int:
products = Path(temp)
source = products / "source"
from scripts.bundles.payload import snapshot
from scripts.build.icon_environment import prepare_icon_environment
snapshot(ROOT, args.ref, source)
# The staging interpreter need not be a Hermes runtime; render icons on one.
icon_python = prepare_icon_environment(source, products / "icon-environment", args.cache)
env = {**os.environ, "HERMES_PYTHON": str(icon_python)}
commands = [
["scripts/build/node-deps.mjs", "--source", str(source), "--workspace", "ui-tui", "--workspace", "web"],
["scripts/generate-icons.mjs", "--source", str(source), "--out", str(products / "icons")],
@@ -45,7 +50,7 @@ def main(argv=None) -> int:
["scripts/build/web.mjs", "--source", str(source), "--icons", str(products / "icons"), "--out", str(products / "web")],
]
for command in commands:
subprocess.run([node, *command], cwd=ROOT, check=True)
subprocess.run([node, *command], cwd=ROOT, env=env, check=True)
args.frontends = {"tui": products / "tui", "web": products / "web"}
return stage_native(args)

View File

@@ -9,8 +9,9 @@
* - installer: apps/bootstrap-installer/package.json prebuild
* - web: web/package.json prebuild
*
* The locked icon-build group runs outside the application environment.
* It is not a runtime extra, so --all-extras payloads do not include resvg.
* The renderer runs on the Hermes runtime interpreter (HERMES_PYTHON, else
* `python` on PATH): Pillow and resvg-py are core dependencies, so every
* runtime environment can draw its own icons.
*/
import { spawnSync } from 'node:child_process'
import path from 'node:path'
@@ -22,21 +23,20 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'
export function generateIcons(args = [], { root = repoRoot, run = spawnSync, env = process.env } = {}) {
const { values } = parseArgs({ args, options: {
source: { type: 'string' }, out: { type: 'string' }, check: { type: 'boolean' },
'on-demand': { type: 'boolean' },
} })
const source = path.resolve(values.source ?? root)
const out = path.resolve(values.out ?? source)
const childEnv = { ...env }
// Parent payload paths must not shadow the isolated build dependencies.
// Parent payload paths must not shadow the runtime interpreter's own packages.
delete childEnv.PYTHONPATH
delete childEnv.PYTHONHOME
const result = run(env.HERMES_PYTHON || 'python', [
path.join(root, 'scripts', 'build', 'icon_environment.py'), '--source', source, '--out', out,
...(values.check ? ['--check'] : []), ...(values['on-demand'] ? ['--on-demand'] : [])
'-I', path.join(root, 'scripts', 'generate_icons.py'), '--source', source, '--out', out,
...(values.check ? ['--check'] : [])
], { cwd: source, stdio: 'inherit', windowsHide: true, env: childEnv })
if (result.error) {
console.error('[generate-icons] failed to launch icon generator:', result.error.message)
console.error('[generate-icons] a prepared Python (HERMES_PYTHON or PATH) is required to run the PM build driver')
console.error('[generate-icons] a Hermes runtime Python (HERMES_PYTHON or PATH) is required')
return 1
}
return result.status ?? 1

View File

@@ -47,9 +47,9 @@ byte-compare against.
Rendering: resvg (resvg-py) for SVG -> PNG fidelity at every size.
Containers: Pillow for multi-size .ico and .icns.
Build dependencies:
The runner uses the locked icon-build group in an isolated environment.
resvg-py is not a runtime extra and does not enter production payloads.
Dependencies:
Pillow and resvg-py are core runtime dependencies; run this file with a
Hermes runtime interpreter (scripts/generate-icons.mjs uses HERMES_PYTHON).
Outputs (30 files):
assets/icon-master.svg generated light master
@@ -102,13 +102,13 @@ try:
import resvg_py
except ImportError:
sys.exit(
"resvg-py is build-only. Run the isolated generator with:\n"
" node scripts/generate-icons.mjs"
"resvg-py is missing: run the generator with a Hermes runtime interpreter\n"
" (HERMES_PYTHON=<hermes venv python> node scripts/generate-icons.mjs)"
)
# Copy of hermes_cli.update_channel._CANARY_TAG_RE: this renderer runs in an
# isolated icon-build venv (Nix, Docker, PM) where the application package is
# absent. tests/scripts/test_icon_flavors.py pins it to the canonical one.
# Copy of hermes_cli.update_channel._CANARY_TAG_RE: builders run this renderer
# on the runtime dependencies without the application package installed
# (Docker, bundles). tests/scripts/test_icon_flavors.py pins it to the canonical one.
_CANARY_TAG_RE = re.compile(
r"^v(?:0|[1-9]\d{0,2})\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
r"\+canary\.20\d{6}T\d{6}Z$"

View File

@@ -31,14 +31,11 @@ print('PM interpreter and application dependencies load as hermes')
def test_dashboard_ships_generated_icon_without_build_environment(built_image: str) -> None:
probe = """
from pathlib import Path
import importlib.util
from PIL import Image
with Image.open('/opt/hermes/hermes_cli/web_dist/favicon.ico') as image:
image.load()
assert image.width > 0 and image.height > 0
assert importlib.util.find_spec('resvg_py') is None
assert not Path('/opt/hermes/.cache/icon-build').exists()
assert not Path('/opt/hermes/node_modules/vite').exists()
assert not Path('/opt/hermes/node_modules/esbuild').exists()
assert Path('/opt/hermes/node_modules/typescript/bin/tsc').is_file()

View File

@@ -188,21 +188,6 @@ def test_public_dependency_only_build_needs_no_application_source(installable_pr
assert not Path(env["HERMES_HOME"]).exists()
def test_group_only_build_excludes_application_dependencies(locked_project, tmp_path, build_worker):
import pm
source, _, env = locked_project
metadata = source / "pyproject.toml"
metadata.write_text(metadata.read_text() + '\n[dependency-groups]\nicons=["chosen-dep==1.0"]\n')
pm.lock_project(source, python=Path(sys.executable), cache=tmp_path / "cache", env=env,
offline=True, explicit=True)
python = pm.build_environment(source=source, out=tmp_path / "icons", groups=["icons"],
only_groups=True, python=Path(sys.executable), cache=tmp_path / "cache",
env=env, offline=True, explicit=True)
assert _run([str(python), "-I", "-c", "import chosen_dep, importlib.util; "
"assert importlib.util.find_spec('base_dep') is None; print(chosen_dep.__version__)"],
cwd=tmp_path, env=env) == "1.0"
@pytest.mark.parametrize("lazy", [False, True])
def test_first_bundle_extension_preserves_shipped_extras(locked_project, build_worker, tmp_path, monkeypatch, lazy):

View File

@@ -295,45 +295,3 @@ def test_native_cache_leaves_room_for_sdist_compiler_outputs(tmp_path, monkeypat
/ f"build/temp.{architecture}-cpython-314/Release/src/SKP_SILK_SRC"
/ "SKP_Silk_NLSF_VQ_rate_distortion_FIX.obj")
assert len(str(output)) < 260, str(output)
@pytest.mark.parametrize("on_demand", [False, True])
def test_icon_environment_prepares_locked_group_before_generation(tmp_path, monkeypatch, on_demand):
import pm
from scripts.build import icon_environment
source, out, cache = (tmp_path / name for name in ("source", "icons-venv", "build-cache"))
python = out / "bin/python"
acquired = []
def build(**kwargs):
acquired.append(kwargs)
return python
monkeypatch.setattr(pm, "build_environment", build)
assert icon_environment.prepare_icon_environment(source, out, cache) == python
assert acquired == [{"source": source, "out": out, "cache": cache,
"groups": ["icon-build"], "only_groups": True, "explicit": True}]
calls = []
def prepare(source, out, cache, *, explicit=True):
calls.append((source, out, cache, explicit))
return python
launched = []
def run(command, *, cwd):
launched.append(command)
assert calls and cwd == source
return SimpleNamespace(returncode=9)
monkeypatch.setattr(icon_environment, "prepare_icon_environment", prepare)
monkeypatch.setattr(icon_environment.subprocess, "run", run)
args = ["--source", str(source), "--out", str(tmp_path / "icons")]
if on_demand:
args.append("--on-demand")
assert icon_environment.main(args) == 9
assert calls[0][0] == source and calls[0][2] == source / ".cache/icon-build"
assert calls[0][3] is not on_demand
assert launched[0][:2] == [str(python), "-I"]
assert "--on-demand" not in launched[0]

View File

@@ -1,22 +0,0 @@
"""Icon tooling must not join the runtime's all-extras dependency set."""
import re
import shutil
import subprocess
from pathlib import Path
def test_icon_group_is_separate_from_runtime_extras():
root = Path(__file__).resolve().parents[2]
uv = shutil.which("uv")
assert uv, "uv is required for the dependency-selection contract"
def packages(*selection):
result = subprocess.run(
[uv, "export", "--frozen", "--offline", "--no-hashes", "--no-annotate",
"--no-header", "--no-emit-project", *selection],
cwd=root, capture_output=True, text=True, encoding="utf-8", check=True,
)
return set(re.findall(r"^([a-z0-9-]+)==", result.stdout, re.MULTILINE))
assert "resvg-py" not in packages("--all-extras")
assert packages("--only-group", "icon-build") == {"pillow", "resvg-py"}

View File

@@ -1,12 +1,9 @@
"""Build native icons with PM's locked renderer. Measure pixels, not SVG text."""
"""Build native icons on the runtime interpreter. Measure pixels, not SVG text."""
import colorsys
import io
import itertools
import os
import json
import shutil
import tomllib
import zipfile
from pathlib import Path
import struct
import subprocess
@@ -28,40 +25,30 @@ def generate(tmp_path_factory):
foreign.mkdir()
(foreign / "sitecustomize.py").write_text("raise SystemExit('foreign interpreter path leaked')\n", encoding="utf-8")
shutil.copytree(ROOT / "assets", source / "assets")
# A real app-only wheel makes --only-group load-bearing: startup dies if
# the driver accidentally includes application dependencies in the renderer.
from tests.pm._fixtures import _wheel
wheel = _wheel(source, "application_only")
with zipfile.ZipFile(wheel, "a") as archive:
archive.writestr("application_only.pth", "import sys; sys.exit('application dependency leaked into icon renderer')\n")
group = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8-sig"))["dependency-groups"]["icon-build"]
(source / "pyproject.toml").write_text(
'[project]\nname="icon-fixture"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=["application-only==1.0"]\n[dependency-groups]\nicon-build=' + json.dumps(group) + '\n'
'[tool.uv]\npackage=false\n[tool.uv.sources]\napplication-only={path=' + json.dumps(wheel.as_posix()) + '}\n', encoding="utf-8")
uv, node = shutil.which("uv"), shutil.which("node")
assert uv and node, "icon acceptance requires prepared uv and Node"
subprocess.run([uv, "lock", "--python", sys.executable], cwd=source, check=True, capture_output=True, timeout=60)
from scripts.build.icon_environment import prepare_icon_environment
python = prepare_icon_environment(ROOT, root / "runtime", root / "cache")
node = shutil.which("node")
assert node, "icon acceptance requires prepared Node"
outputs = {}
sequence = itertools.count()
def build(tag="", commit="", *, rejected=False, on_demand=False):
key = (tag, commit, on_demand)
def build(tag="", commit="", *, rejected=False):
key = (tag, commit)
if key not in outputs:
out = root / str(next(sequence))
# The runtime interpreter renders with its own packages: foreign
# interpreter paths must not leak in, and nothing may be installed.
env = {**os.environ, "HERMES_HOME": str(root / "home"),
"HERMES_RUNTIME_DIR": str(root / "tools"),
"HERMES_PAYLOAD_TAG": tag, "HERMES_BUILD_COMMIT": commit,
"HERMES_PYTHON": sys.executable, "PYTHONPATH": str(root / "foreign-site"),
"HERMES_PYTHON": str(python), "PYTHONPATH": str(root / "foreign-site"),
"PYTHONHOME": str(root / "foreign-python"), "HERMES_DISABLE_LAZY_INSTALLS": "1"}
command = [node, str(ROOT / "scripts/generate-icons.mjs"),
"--source", str(source), "--out", str(out), *(["--on-demand"] if on_demand else [])]
"--source", str(source), "--out", str(out)]
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=180)
if rejected:
assert result.returncode != 0, "invalid build identity generated icons"
assert not out.exists()
if on_demand:
assert "disabled" in (result.stdout + result.stderr).lower()
return
assert result.returncode == 0, result.stdout + result.stderr
if not outputs:
@@ -73,10 +60,6 @@ def generate(tmp_path_factory):
return build
def test_on_demand_build_obeys_disabled_lazy_install_admission(generate):
generate(on_demand=True, rejected=True)
def frames(path):
"""Read every native frame, including ICO entries Pillow's n_frames misses."""
data = path.read_bytes()

10
uv.lock generated
View File

@@ -2040,6 +2040,7 @@ dependencies = [
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "requests" },
{ name = "resvg-py" },
{ name = "rich" },
{ name = "ruamel-yaml" },
{ name = "snowballstemmer" },
@@ -2314,10 +2315,6 @@ dev = [
{ name = "starlette" },
{ name = "ty" },
]
icon-build = [
{ name = "pillow" },
{ name = "resvg-py" },
]
test = [
{ name = "distlib", marker = "sys_platform == 'win32'" },
]
@@ -2448,6 +2445,7 @@ requires-dist = [
{ name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" },
{ name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" },
{ name = "requests", marker = "python_full_version >= '3.14'", specifier = "==2.33.0" },
{ name = "resvg-py", marker = "python_full_version >= '3.14'", specifier = "==0.4.0" },
{ name = "rich", marker = "python_full_version >= '3.14'", specifier = "==14.3.3" },
{ name = "ruamel-yaml", marker = "python_full_version >= '3.14'", specifier = "==0.18.16" },
{ name = "sentencepiece", marker = "extra == 'wake'", specifier = "==0.2.2" },
@@ -2501,10 +2499,6 @@ dev = [
{ name = "starlette", specifier = "==1.3.1" },
{ name = "ty", specifier = "==0.0.82" },
]
icon-build = [
{ name = "pillow", specifier = "==12.3.0" },
{ name = "resvg-py", specifier = "==0.4.0" },
]
test = [{ name = "distlib", marker = "sys_platform == 'win32'", specifier = "==0.4.3" }]
[[package]]

View File

@@ -118,9 +118,9 @@ Use a Node/npm version accepted by the corresponding `package.json` engines.
Native desktop dependencies can also require the platform build toolchain.
Logos and icons are generated from `assets/nous-girl-*.svg` and
`assets/backgrounds/`. `node scripts/generate-icons.mjs` uses the locked,
isolated `icon-build` dependency group. Do not commit generated PNG/ICO/ICNS
outputs or add icon renderers to production dependencies.
`assets/backgrounds/`. `node scripts/generate-icons.mjs` renders them with the
Hermes runtime Python (`HERMES_PYTHON`, else `python` on PATH): Pillow and
resvg-py are core dependencies. Do not commit generated PNG/ICO/ICNS outputs.
### Run tests

View File

@@ -21,7 +21,7 @@ bytes, dependency selections, or Python environments across targets.
| Product | Implementation | Inputs |
|---|---|---|
| Icons | `scripts/generate_icons.py` | Artwork and prepared `icon-build` environment |
| Icons | `scripts/generate_icons.py` | Artwork and a Python with the runtime dependencies |
| TUI | `scripts/build/tui.mjs` | Prepared TUI workspace |
| Dashboard | `scripts/build/web.mjs` | Prepared web workspace and generated icons |
| Desktop UI | `scripts/build/desktop.mjs` | Prepared desktop workspace, icons, stamp, and native bindings |
@@ -326,9 +326,10 @@ consumers. They are not interchangeable cleanup targets.
desktop preparation uses its own input snapshot namespace.
Plain `uv cache prune` removes dangling entries without discarding offline
wheel inputs. It does not remove all historical versions or enforce a size cap.
- Standalone icon preparation uses `SOURCE/.cache/icon-build`. Desktop
preparation places its icon environment under the job workdir and its wheel
cache under `CACHE/python/build`. Neither enters the shipped runtime.
- Source builds render icons on their runtime interpreter. Desktop preparation
and product staging, which have none, prepare the locked runtime dependencies
(without the application) under the job workdir; desktop's wheel cache is
`CACHE/python/build`. That environment does not enter the shipped runtime.
- Frontend `node_modules` is a provider input, not a frontend product.
Docker's runtime TypeScript and Photon selections are separate exceptions.

View File

@@ -463,7 +463,6 @@ Use the public `pm` module for Python dependency work:
| `pm.sync_venv(extras, explicit=True)` | Prepare and select the complete application dependency union, including enabled plugins. |
| `pm.sync_venv(repair=True, explicit=True)` | Replay the recorded dependency set in a new application generation. |
| `pm.build_environment(source=..., out=..., explicit=True)` | Build and validate a fresh caller-owned output. No plugin discovery or application selection. |
| `pm.build_environment(source=..., out=..., groups=[...], only_groups=True, explicit=True)` | Build only the selected locked dependency groups, without application dependencies. Used by icon builds. |
| `pm.lock_project(source, explicit=True)` | Refresh an explicit project's lock without selecting an environment. |
| `pm.ensure_environment(name, requirements, explicit=True)` | Prepare and select an isolated dependency generation. Return its Python path. |
| `pm.ensure_python_tool(name, requirements, executable, explicit=True)` | Prepare an isolated tool and return its executable path. |

View File

@@ -91,7 +91,7 @@ npm run build:fast --prefix website
```
图标从 `assets/nous-girl-*.svg` 和 `assets/backgrounds/` 生成。
`node scripts/generate-icons.mjs` 使用隔离的 `icon-build` 依赖组,不应将这些构建依赖加入生产包。
`node scripts/generate-icons.mjs` 使用 Hermes 运行时 Python(`HERMES_PYTHON`,否则为 PATH 上的 `python`)渲染图标:Pillow 和 resvg-py 是核心依赖。不要提交生成的 PNG/ICO/ICNS 文件。
### 运行测试

View File

@@ -132,7 +132,7 @@ console.log("[prebuild] generating icon assets…");
{
const r = spawnSync("node", [iconGenScript, "--source", repoRoot, "--out", repoRoot], { stdio: "inherit", cwd: repoRoot });
if (r.status !== 0) {
console.error("[prebuild] icon generation failed — see the isolated icon-build runner output above");
console.error("[prebuild] icon generation failed — it needs a Hermes runtime Python (HERMES_PYTHON); see the output above");
process.exit(1);
}
}