Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/linux-native-sdk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Linux native SDK
on:
push:
branches: [codex/linux-headless-sdk]
pull_request:
paths:
- 'src/WebScene.NativeWeb/**'
- 'src/WebScene.Sdk/**'
- 'tests/NativeWeb/**'
- 'eng/graphics/**'
- 'eng/sdk/**'
- '.github/workflows/linux-native-sdk.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: linux-native-sdk-${{ github.ref }}
cancel-in-progress: true
jobs:
native:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Record source input
run: |
mkdir -p artifacts/linux-native
git rev-parse HEAD > artifacts/linux-native/source-revision.txt
git archive --format=tar.gz HEAD > artifacts/linux-native/source.tar.gz
- name: Install compiler and native prerequisites
run: |
sudo apt-get update
sudo apt-get install -y patchelf ninja-build libfontconfig1-dev libfreetype-dev libvulkan-dev mesa-vulkan-drivers vulkan-tools
python3 -m pip install --break-system-packages cmake==3.31.6
- uses: actions/cache@v4
with:
path: ~/.cache/webscene/llvm-22.1.1
key: linux-cxx-llvm-22.1.1-efc4d945744f951d-v2
- name: Install checksum-pinned LLVM
run: |
python3 eng/sdk/install-linux-llvm.py "$HOME/.cache/webscene/llvm-22.1.1"
echo "WEBSCENE_LLVM_ROOT=$HOME/.cache/webscene/llvm-22.1.1" >> "$GITHUB_ENV"
echo "$HOME/.cache/webscene/llvm-22.1.1/bin" >> "$GITHUB_PATH"
- name: Archive offline compiler profile
run: python3 eng/sdk/archive-linux-llvm.py "$WEBSCENE_LLVM_ROOT" artifacts/linux-native/llvm.tar.gz
- uses: actions/upload-artifact@v4
with:
name: linux-native-compiler
path: artifacts/linux-native/llvm.tar.gz
compression-level: 0
retention-days: 7
- name: Build native document, shared CSS and UI compiler
run: |
cmake -S src/WebScene.NativeWeb -B artifacts/linux-native/build -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_TOOLCHAIN_FILE="$PWD/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake"
cmake --build artifacts/linux-native/build --parallel 2
ctest --test-dir artifacts/linux-native/build --output-on-failure
- name: Install native-only SDK without V8
run: |
cmake -S src/WebScene.Sdk -B artifacts/linux-native/sdk-build -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DWEBSCENE_SDK_WEBGPU=OFF \
-DCMAKE_TOOLCHAIN_FILE="$PWD/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake" \
-DCMAKE_INSTALL_PREFIX="$PWD/artifacts/linux-native/sdk"
cmake --build artifacts/linux-native/sdk-build --parallel 2
cmake --install artifacts/linux-native/sdk-build
python3 eng/sdk/stage-linux-runtime.py --llvm "$WEBSCENE_LLVM_ROOT" --sdk artifacts/linux-native/sdk
tar -czf artifacts/linux-native/sdk.tar.gz -C artifacts/linux-native sdk
- uses: actions/upload-artifact@v4
if: always()
with:
name: linux-native-evidence
path: |
artifacts/linux-native/source.tar.gz
artifacts/linux-native/source-revision.txt
artifacts/linux-native/sdk.tar.gz
artifacts/linux-native/build/Testing/Temporary/*.log
if-no-files-found: error
retention-days: 7
39 changes: 39 additions & 0 deletions .github/workflows/linux-sdk-contracts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Linux SDK source contracts
on:
push:
branches: [codex/linux-headless-sdk]
pull_request:
paths: ['eng/sdk/**', 'experiments/WebScene.NativeEngine.Probe/native/graphics/**', 'src/WebScene.Sdk/**', '.github/workflows/linux-sdk-contracts.yml']
permissions:
contents: read
jobs:
contracts:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Header scanner regressions and public dependency closure
run: |
python3 eng/sdk/test-header-closure.py
python3 - <<'PY'
import importlib.util
from pathlib import Path
import sys
script=Path('eng/sdk/header-closure.py')
spec=importlib.util.spec_from_file_location('closure',script)
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
root=Path.cwd();native=root/'experiments/WebScene.NativeEngine.Probe/native';authoring=root/'src/WebScene.NativeWeb/include'
pending=[(p,[]) for p in list(authoring.rglob('*.hpp'))+[native/'webscene/compiled_document.hpp',native/'graphics/native_webgpu_surface.h']]
seen=set()
while pending:
item,parents=pending.pop();item=item.resolve()
if item in seen:continue
seen.add(item)
if not any(item.is_relative_to(p) for p in (native,authoring)):
raise RuntimeError('Outside SDK header roots: '+' -> '.join(str(p.relative_to(root)) for p in parents+[item]))
for name in module.includes(item.read_text()):
candidate=next((p for p in [item.parent/name,authoring/name,native/name,native/'graphics'/name] if p.is_file()),None)
if candidate:pending.append((candidate,parents+[item]))
print('Verified',len(seen),'public header dependencies')
module.generate(root)
PY
39 changes: 39 additions & 0 deletions docs/guides/linux-native-headless-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Linux native headless SDK profile

The Linux x86_64 SDK is a Native-only producer profile. It installs `WebScene::Core`, `NativeWeb`, `SharedCSS`, `Compiler`, and optionally `WebGPU`. It does not build or package V8, the Runtime component, application scripts or runtime-loaded application HTML. The compiler/ABI pin is LLVM 22.1.1 with libc++. See `src/WebScene.Sdk/cmake/WebSceneLinuxSDK.cmake` and the checksum-pinned installer in `eng/sdk/install-linux-llvm.py`.

HTML/templates are still compiled to C++20 at build time. Applications explicitly select `CSS_BACKEND shared`; native CSS parsing is allowed. The Linux package exports relocatable imported targets and a platform marker so a macOS binary SDK cannot accidentally be consumed on Linux. Requesting unavailable Runtime components fails rather than substituting another host.

## Offscreen WebGPU

On Linux, `native_webgpu_surface` names the reusable `native_headless_webgpu_surface`. The macOS IOSurface and Windows DXGI implementations remain unchanged. The Linux surface uses pinned Dawn Vulkan textures and the existing immutable image-lease/pool contracts. It is an offscreen native authoring/presentation target, **not** a claim of Linux desktop dma-buf/opaque-FD interop, X11/Wayland composition or Avalonia/Uno GPU parity.

Three color allocations are admitted at most, under an explicit byte budget. A retained image remains usable across resize and after surface close; only idle slots can be reclaimed. Producer work retires from actual Dawn queue completion, independently of application frame pumping. Exhausted slots return an empty texture rather than blocking the owner thread or creating an unbounded queue. Adapter identity is exposed, unknown adapters are rejected, and software devices require explicit authorization (`WEBSCENE_HEADLESS_ALLOW_SOFTWARE=1`). CI additionally forces a fallback adapter and records it as software, never hardware qualification.

`capture_native_image` is an explicit native diagnostic/export API. The provider interface does not make CPU-only hosts link Dawn. Captures use bounded GPU-to-buffer copies, wait only on the explicit diagnostic operation, and retain the consumer until GPU copy completion even when mapping times out. Normal frame publication performs no CPU readback. Only top-left 8-bit sRGB images are currently composed by the paired AppScene headless PNG path; unsupported formats fail explicitly.

## Build and validation

```sh
python3 eng/sdk/install-linux-llvm.py "$HOME/.cache/webscene/llvm-22.1.1"
export WEBSCENE_LLVM_ROOT="$HOME/.cache/webscene/llvm-22.1.1"
cmake -S src/WebScene.Sdk -B build-sdk -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$PWD/src/WebScene.Sdk/cmake/WebSceneToolchain.cmake" \
-DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX="$PWD/sdk" \
-DWEBSCENE_SDK_WEBGPU=OFF
cmake --build build-sdk --parallel 3
cmake --install build-sdk
python3 eng/sdk/stage-linux-runtime.py --llvm "$WEBSCENE_LLVM_ROOT" --sdk sdk
cmake -S tests/Headless -B build-consumer -G Ninja \
-DCMAKE_PREFIX_PATH="$PWD/sdk" \
-DCMAKE_TOOLCHAIN_FILE="$PWD/sdk/lib/cmake/WebScene/WebSceneToolchain.cmake" \
-DHEADLESS_TEST_WEBGPU=OFF
cmake --build build-consumer --parallel 3
ctest --test-dir build-consumer --output-on-failure
```

For GPU qualification, first build the existing pinned Dawn Linux package with `eng/graphics/build.py dawn --rid linux-x64`, then set `WEBSCENE_SDK_WEBGPU=ON`, `WEBSCENE_GRAPHICS_SDK_ROOT=<graphics-sdk>/linux-x64`, and `HEADLESS_TEST_WEBGPU=ON`. Use the same LLVM/libc++ producer toolchain. Ubuntu 24.04 prerequisites and explicit software-Vulkan configuration are in the paired AppScene workflow.

The installed-consumer tests cover compiled shared-CSS document construction, template identity, native pointer/input interaction, theme layout, bounded frame ownership, idle producer progress, exact captured pixels, retained resize, capture budgets and repeated teardown. They also compile the existing native Kestrel GPU modules against the installed SDK and run the original pipeline, mesh/line pixel, scene invalidation, pending-frame and resize assertions. The only platform change in that original test is Vulkan adapter selection on Linux.

The paired AppScene PR supplies the control host, deterministic stepping, screenshots, native application samples, relocation/bundle auditing and end-to-end tests. This infrastructure does not complete the original JavaScript Kestrel migration. Software-Vulkan results do not establish hardware performance, full conformance, desktop external-memory sharing, media support, or browser/WebGL parity. Issue #46 and the parent graphics epic remain open until their separate acceptance gates are met.
19 changes: 19 additions & 0 deletions eng/sdk/archive-linux-llvm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Archive only the verified native C++ compiler profile for offline consumers."""
import argparse
import importlib.util
from pathlib import Path
import tarfile

p = argparse.ArgumentParser(description=__doc__)
p.add_argument("source", type=Path)
p.add_argument("output", type=Path)
a = p.parse_args()
spec = importlib.util.spec_from_file_location("installer", Path(__file__).with_name("install-linux-llvm.py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if not (a.source / "webscene-toolchain.json").exists():
raise RuntimeError("Compiler provenance marker missing")
a.output.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(a.output, "w:gz", compresslevel=3) as archive:
archive.add(a.source, arcname="llvm-22.1.1", filter=lambda item: item if module.keep(item) or item.name.endswith("webscene-toolchain.json") else None)
67 changes: 46 additions & 21 deletions eng/sdk/header-closure.py
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,25 +1,50 @@
#!/usr/bin/env python3
"""Generate installation rules for public SDK headers and their local includes."""
"""Generate install rules for actual local includes of public SDK headers."""
from pathlib import Path
import re
import sys
root=Path(sys.argv[1]).resolve()
native=root/'experiments/WebScene.NativeEngine.Probe/native'
authoring=root/'src/WebScene.NativeWeb/include'
roots=[authoring,native,native/'graphics']
pending=list(authoring.rglob('*.hpp'))+[native/'webscene/compiled_document.hpp',native/'graphics/native_webgpu_surface.h']
seen=set()
while pending:
item=pending.pop().resolve()
if item in seen:continue
seen.add(item)
for included in re.findall(r'^\s*#\s*include\s*[<"]([^">]+)[">]',item.read_text(),re.M):
candidates=[item.parent/included]+[base/included for base in roots]
match=next((candidate for candidate in candidates if candidate.is_file()),None)
if match:pending.append(match)
elif included.startswith(('webscene_','webscene/')):
raise RuntimeError(f'Unresolved SDK header dependency {included} in {item}')
for item in sorted(seen):
relative=item.relative_to(authoring if item.is_relative_to(authoring) else native)
print(f'install(FILES "{item}" DESTINATION "include/{relative.parent.as_posix()}")')
print(f'set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "{item}")')

TOKENS = re.compile(
r'(?P<include>^[ \t]*\#[ \t]*include[ \t]*[<"](?P<path>[^">\r\n]+)[">])'
r'|(?:u8|u|U|L)?R"(?P<delimiter>[^ ()\\\t\r\n]{0,16})\(.*?\)(?P=delimiter)"'
r'|/\*.*?\*/|//[^\r\n]*'
r'|"(?:\\.|[^"\\\r\n])*"'
r'|(?<![\w])\x27(?:\\.|[^\x27\\\r\n])*\x27',
re.MULTILINE | re.DOTALL,
)

def includes(text):
return [m.group('path') for m in TOKENS.finditer(text) if m.group('include')]

def generate(root):
root=Path(root).resolve()
native=root/'experiments/WebScene.NativeEngine.Probe/native'
authoring=root/'src/WebScene.NativeWeb/include'
roots=[authoring,native,native/'graphics']
pending=[(path,[]) for path in sorted(authoring.rglob('*.hpp'))+[native/'webscene/compiled_document.hpp',native/'graphics/native_webgpu_surface.h']]
seen=set()
while pending:
item,chain=pending.pop();item=item.resolve()
if item in seen:continue
if not any(item.is_relative_to(base) for base in (authoring,native)):
raise RuntimeError('Public header includes a producer-only source: '+' -> '.join(str(path.relative_to(root)) for path in chain+[item]))
seen.add(item)
for included in includes(item.read_text()):
candidates=[item.parent/included]+[base/included for base in roots]
match=next((candidate for candidate in candidates if candidate.is_file()),None)
if match:pending.append((match,chain+[item]))
elif included.startswith(('webscene_','webscene/')):
raise RuntimeError(f'Unresolved SDK header dependency {included} in {item}')
for item in sorted(seen):
relative=item.relative_to(authoring if item.is_relative_to(authoring) else native)
print(f'install(FILES "{item}" DESTINATION "include/{relative.parent.as_posix()}")')
print(f'set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "{item}")')
json_root=root/'samples/NativeKestrel/third_party/nlohmann'
print('if(CMAKE_SYSTEM_NAME STREQUAL "Linux")')
print(f'install(FILES "{json_root}/json.hpp" DESTINATION include/third_party/nlohmann)')
print(f'install(FILES "{json_root}/LICENSE.MIT" DESTINATION share/licenses/WebScene RENAME nlohmann-LICENSE)')
print('endif()')
print(f'WebScene SDK header closure: {len(seen)} files',file=sys.stderr)

if __name__=='__main__':
generate(sys.argv[1])
67 changes: 67 additions & 0 deletions eng/sdk/install-linux-llvm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Install the checksum-pinned Linux C++ compiler without unrelated LLVM SDKs."""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request

VERSION = "22.1.1"
URL = "https://github.com/llvm/llvm-project/releases/download/llvmorg-22.1.1/LLVM-22.1.1-Linux-X64.tar.xz"
SHA256 = "efc4d945744f951df00ec72c5b31da5d5a2eaf1d53cc7c9d0644f93f0f9e817d"
BINARIES = {"clang", "clang++", "clang-22", "clang-scan-deps", "llvm-ar", "llvm-ranlib",
"lld", "ld.lld", "llvm-strip", "llvm-nm", "llvm-readobj", "llvm-objdump", "llvm-objcopy"}

def keep(member):
parts = PurePosixPath(member.name).parts[1:]
if not parts:
return True
if parts[0] == "bin":
return len(parts) == 1 or parts[1] in BINARIES
if parts[0] == "include":
return True
if parts[0] == "lib":
return member.isdir() or "clang" in parts or ".so" in parts[-1] or parts[-1].endswith(".ld")
return parts[-1].startswith(("LICENSE", "NOTICE")) or parts[:2] == ("share", "licenses")

def install(destination):
destination = destination.resolve()
stamp = destination / "webscene-toolchain.json"
if stamp.is_file():
if json.loads(stamp.read_text()).get("archiveSha256") != SHA256:
raise RuntimeError("Refusing a different compiler cache")
subprocess.run([destination / "bin/clang++", "--version"], check=True)
return
if destination.exists():
raise RuntimeError("Refusing an incomplete or unrelated installation: " + str(destination))
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="llvm-install-", dir=destination.parent) as work:
root = Path(work)
archive = root / "llvm.tar.xz"
digest = hashlib.sha256()
with urllib.request.urlopen(URL, timeout=120) as response, archive.open("wb") as output:
while chunk := response.read(1024 * 1024):
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != SHA256:
raise RuntimeError("LLVM archive checksum mismatch")
extracted = root / "extracted"
extracted.mkdir()
with tarfile.open(archive) as source:
source.extractall(extracted, members=(m for m in source if keep(m)), filter="data")
children = list(extracted.iterdir())
if len(children) != 1 or not (children[0] / "bin/clang++").exists():
raise RuntimeError("Unexpected LLVM archive layout")
subprocess.run([children[0] / "bin/clang++", "--version"], check=True)
shutil.move(str(children[0]), destination)
stamp.write_text(json.dumps({"version": VERSION, "archiveSha256": SHA256, "url": URL,
"profile": "native-cxx"}, indent=2) + "\n")

if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("destination", type=Path)
install(parser.parse_args().destination)
49 changes: 49 additions & 0 deletions eng/sdk/stage-linux-runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Copy the pinned C++ shared ABI runtime and its licenses into an installed SDK."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import subprocess


def stage(llvm, sdk):
llvm, sdk = llvm.resolve(), sdk.resolve()
destination = sdk / "lib"
destination.mkdir(parents=True, exist_ok=True)
selected = {}
for pattern in ("libc++.so*", "libc++abi.so*", "libunwind.so*"):
candidates = sorted((llvm / "lib").rglob(pattern))
if not candidates:
raise RuntimeError("Missing pinned C++ runtime: " + pattern)
for source in candidates:
# Copy symlink targets too, preserving the SONAME used by ELF consumers.
if not source.is_file():
continue
raw = source.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
if source.name in selected and selected[source.name] != digest:
raise RuntimeError("Ambiguous C++ runtime architecture: " + source.name)
selected[source.name] = digest
target = destination / source.name
shutil.copyfile(source, target, follow_symlinks=True)
if raw.startswith(b"\x7fELF"):
subprocess.run(["patchelf", "--set-rpath", "$ORIGIN", target], check=True)
licenses = sdk / "share/licenses/WebScene/LLVM"
licenses.mkdir(parents=True, exist_ok=True)
for source in llvm.rglob("LICENSE*.TXT"):
shutil.copyfile(source, licenses / ("-".join(source.relative_to(llvm).parts)))
metadata = sdk / "share/webscene"
metadata.mkdir(parents=True, exist_ok=True)
(metadata / "linux-cxx-runtime.json").write_text(json.dumps({
"toolchain": json.loads((llvm / "webscene-toolchain.json").read_text()),
"files": {name: hashlib.sha256((destination / name).read_bytes()).hexdigest() for name in selected},
}, indent=2) + "\n")

if __name__ == "__main__":
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--llvm", type=Path, required=True)
p.add_argument("--sdk", type=Path, required=True)
a = p.parse_args()
stage(a.llvm, a.sdk)
Loading
Loading