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
26 changes: 26 additions & 0 deletions .github/workflows/pull-request-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,16 @@ jobs:
with:
python-version: '3.12'

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'

- uses: jiro4989/setup-nim-action@v1
with:
nim-version: 2.2.4
repo-token: ${{ secrets.GITHUB_TOKEN }}

- name: Install redis
run: sudo apt-get -y update && sudo apt-get install -y redis-server

Expand Down Expand Up @@ -428,6 +438,22 @@ jobs:
mkdir -p ../frontend/dist/static
TEST=1 pytest

frameos-setup-script:
name: Standalone setup script
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Verify Docker
run: docker version

- name: Check shell syntax
run: sh -n scripts/frameos-setup.sh

- name: Run setup script container tests
run: python3 -m unittest scripts.tests.test_frameos_setup -v

deploy-e2e:
name: Deploy E2E SSH
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
- Output folder is consumed by the backend’s static file mounts; ensure `frontend/dist` exists (e.g., via `pnpm --dir frontend run build`) before running the Python app outside of test mode. 【F:backend/app/fastapi.py†L38-L86】
- ALWAYS prefer writing frontend business logic in kea logic files over using effects like `useState` or `useEffect`.
- This includes small functions and callbacks inside components. Prefer to keep as much code as possible in logic files, treating React as a templating layer.
- When adding a frame model key that is tracked for deploy changes in `frontend/src/scenes/frame/frameLogic.ts`, also add a marker to `FRAME_KEY_INTRODUCED_FRAMEOS_VERSION`. For unreleased work, use the next patch after the current `versions.json` FrameOS base version.

## Device runtime (Nim) notes
- `frameos/frameos` houses the on-device runtime written in Nim with asyncdispatch.
Expand Down
13 changes: 11 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ RUN nim --version && nimble --version
FROM nim-toolchain AS app-builder

ARG FRAMEOS_ARCHIVE_BASE_URL=https://archive.frameos.net
ARG QUICKJS_VERSION=2025-04-26
ARG QUICKJS_SHA256=2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf
ARG QUICKJS_VERSION=2026-06-04
ARG QUICKJS_SHA256=b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a

ENV DEBIAN_FRONTEND=noninteractive

Expand Down Expand Up @@ -136,6 +136,14 @@ RUN find /app/frameos -path '*/tests' -type d -prune -exec rm -rf {} + \
/app/frameos/agent/build \
/app/frameos/agent/tmp

WORKDIR /app/frameos
RUN nim c \
--nimCache:/tmp/frameos-native-js-transpile-nimcache \
--out:/app/frameos/build/native_js_transpile \
tools/native_js_transpile.nim \
&& test -x /app/frameos/build/native_js_transpile \
&& rm -rf /tmp/frameos-native-js-transpile-nimcache

FROM ${PYTHON_IMAGE} AS python-deps

ENV DEBIAN_FRONTEND=noninteractive
Expand Down Expand Up @@ -163,6 +171,7 @@ ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV VIRTUAL_ENV=/app/backend/.venv
ENV FRAMEOS_NATIVE_JS_TRANSPILE=/app/frameos/build/native_js_transpile
ENV PATH="/opt/nim/bin:${VIRTUAL_ENV}/bin:${PATH}"

WORKDIR /app
Expand Down
153 changes: 147 additions & 6 deletions backend/app/tasks/buildroot_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import copy
import hashlib
import json
import math
import os
import re
import shlex
Expand Down Expand Up @@ -63,9 +64,11 @@
BUILDROOT_HOST_CFLAGS = "-O2 -pipe"
BUILDROOT_JLEVEL = int(os.environ.get("FRAMEOS_BUILDROOT_JLEVEL", "0"))
BUILDROOT_BOOTSTRAP_SCRIPT_VERSION = "5"
BUILDROOT_SD_IMAGE_CUSTOMIZATION_VERSION = 14
BUILDROOT_FRAMEOS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_FRAMEOS_PARTITION_SIZE", "100M")
BUILDROOT_ASSETS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_ASSETS_PARTITION_SIZE", "100M")
BUILDROOT_SD_IMAGE_CUSTOMIZATION_VERSION = 15
BUILDROOT_FRAMEOS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_FRAMEOS_PARTITION_SIZE", "30M")
BUILDROOT_ASSETS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_ASSETS_PARTITION_SIZE", "30M")
BUILDROOT_DATA_PARTITION_HEADROOM_BYTES = 8 * 1024 * 1024
BUILDROOT_DATA_PARTITION_HEADROOM_RATIO = 1.25
BUILDROOT_LOCAL_FONTS_DIR = REPO_ROOT / "frameos" / "assets" / "copied" / "fonts"
BUILDROOT_LOCAL_FONT_EXTENSIONS = {".ttf", ".txt", ".md"}
BUILDROOT_ARCHIVE_BASE_URL = os.environ.get("FRAMEOS_ARCHIVE_BASE_URL", "https://archive.frameos.net/")
Expand Down Expand Up @@ -1547,6 +1550,10 @@ async def _compose_sd_image_from_base(
if not any(assets_root.iterdir()):
(assets_root / "frameos-assets-placeholder").write_text("", encoding="utf-8")

frameos_partition_size = _partition_size_for_root(
frameos_root,
minimum_size=BUILDROOT_FRAMEOS_PARTITION_SIZE,
)
compose_roots = f"/tmp/frameos-compose-roots-{os.getpid()}-{secure_token(6)}"
genimage_cfg = compose_dir / "frameos-genimage.cfg"
genimage_cfg.write_text(
Expand All @@ -1556,7 +1563,7 @@ async def _compose_sd_image_from_base(
label = "FRAMEOS"
}}
srcpath = "{compose_roots}/frameos"
size = {BUILDROOT_FRAMEOS_PARTITION_SIZE}
size = {frameos_partition_size}
}}

image assets.vfat {{
Expand Down Expand Up @@ -1627,6 +1634,12 @@ async def _compose_sd_image_from_base(

shutil.copy2(base_image_path, output_path)
partitions = _mbr_partitions(output_path)
partitions = _shrink_data_partitions(
output_path,
partitions,
frameos_image=images_dir / "frameos.ext4",
assets_image=images_dir / "assets.vfat",
)
_replace_partition(output_path, partitions, 3, images_dir / "frameos.ext4")
_replace_partition(output_path, partitions, 4, images_dir / "assets.vfat")
await self._patch_boot_partition(output_path, partitions, boot_root, image=image)
Expand Down Expand Up @@ -1655,6 +1668,12 @@ async def _compose_sd_image_from_precompiled_release(
shutil.copy2(release_image_path, output_path)

partitions = _mbr_partitions(output_path)
max_frameos_size = _partition_size_bytes(BUILDROOT_FRAMEOS_PARTITION_SIZE)
max_assets_size = _partition_size_bytes(BUILDROOT_ASSETS_PARTITION_SIZE)
if partitions[2]["size"] > max_frameos_size or partitions[3]["size"] > max_assets_size:
raise RuntimeError(
"Full precompiled Buildroot SD image uses larger data partitions than the current image layout"
)
await self._patch_boot_partition(output_path, partitions, boot_root, image=image)

async def _patch_boot_partition(
Expand Down Expand Up @@ -2146,6 +2165,41 @@ def normalize_path(value: str) -> str:
files+=("$kernel")
fi

partition_size_for_root() {{
python3 - "$1" "$2" <<'PY'
import math
import os
import re
import sys

HEADROOM_BYTES = {BUILDROOT_DATA_PARTITION_HEADROOM_BYTES}
HEADROOM_RATIO = {BUILDROOT_DATA_PARTITION_HEADROOM_RATIO}
MIB = 1024 * 1024

def parse_size(value):
match = re.fullmatch(r"\\s*([0-9]+)\\s*([KMG]?)\\s*", value, flags=re.IGNORECASE)
if not match:
raise SystemExit(f"Unsupported partition size {{value!r}}")
number = int(match.group(1))
unit = match.group(2).upper()
return number * {{"": 1, "K": 1024, "M": MIB, "G": 1024 * MIB}}[unit]

root = sys.argv[1]
minimum = parse_size(sys.argv[2])
payload = 0
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
for name in dirnames + filenames:
try:
payload += os.lstat(os.path.join(dirpath, name)).st_size
except FileNotFoundError:
pass

required = max(minimum, math.ceil(payload * HEADROOM_RATIO) + HEADROOM_BYTES)
print(f"{{math.ceil(required / MIB)}}M")
PY
}}

frameos_partition_size="$(partition_size_for_root "${{BASE_DIR:?BASE_DIR is required}}/frameos-partition-root" "{BUILDROOT_FRAMEOS_PARTITION_SIZE}")"
boot_files="$(printf '\\t\\t\\t"%s",\\n' "${{files[@]}}")"
cat > "$genimage_cfg" <<EOF
image boot.vfat {{
Expand All @@ -2165,7 +2219,7 @@ def normalize_path(value: str) -> str:
label = "FRAMEOS"
}}
srcpath = "${{BASE_DIR:?BASE_DIR is required}}/frameos-partition-root"
size = {BUILDROOT_FRAMEOS_PARTITION_SIZE}
size = $frameos_partition_size
}}

image assets.vfat {{
Expand Down Expand Up @@ -2283,7 +2337,7 @@ def normalize_path(value: str) -> str:
return 0
fi
echo "Moving $name data: $old_start -> $new_start ($sectors sectors)"
chunk_sectors=$((1024 * 1024 / sector_size))
chunk_sectors=$((4 * 1024 * 1024 / sector_size))
if [ "$chunk_sectors" -lt 1 ]; then
chunk_sectors=1
fi
Expand Down Expand Up @@ -2632,6 +2686,93 @@ def _mbr_partitions(image_path: Path) -> list[dict[str, int]]:
return partitions


def _partition_size_bytes(size: str) -> int:
match = re.fullmatch(r"\s*([0-9]+)\s*([KMG]?)\s*", size, flags=re.IGNORECASE)
if not match:
raise RuntimeError(f"Unsupported partition size {size!r}")
value = int(match.group(1))
unit = match.group(2).upper()
multiplier = {
"": 1,
"K": 1024,
"M": 1024 * 1024,
"G": 1024 * 1024 * 1024,
}[unit]
return value * multiplier


def _align_up_bytes(value: int, alignment: int = 1024 * 1024) -> int:
return ((value + alignment - 1) // alignment) * alignment


def _directory_payload_size_bytes(root: Path) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
for name in [*dirnames, *filenames]:
try:
total += (Path(dirpath) / name).lstat().st_size
except FileNotFoundError:
continue
return total


def _partition_size_for_root(root: Path, *, minimum_size: str) -> str:
minimum_bytes = _partition_size_bytes(minimum_size)
payload_bytes = _directory_payload_size_bytes(root)
required_bytes = max(
minimum_bytes,
math.ceil(payload_bytes * BUILDROOT_DATA_PARTITION_HEADROOM_RATIO) + BUILDROOT_DATA_PARTITION_HEADROOM_BYTES,
)
return f"{_align_up_bytes(required_bytes) // (1024 * 1024)}M"


def _set_mbr_partition_geometry(image_path: Path, partition_number: int, *, start: int, size: int) -> None:
if partition_number < 1 or partition_number > 4:
raise RuntimeError(f"Invalid partition number {partition_number}")
if start % 512 != 0 or size % 512 != 0:
raise RuntimeError(f"Partition {partition_number} geometry must be sector-aligned")
with image_path.open("r+b") as image:
image.seek(446 + (partition_number - 1) * 16 + 8)
image.write((start // 512).to_bytes(4, "little"))
image.write((size // 512).to_bytes(4, "little"))


def _shrink_data_partitions(
image_path: Path,
partitions: list[dict[str, int]],
*,
frameos_image: Path,
assets_image: Path,
) -> list[dict[str, int]]:
if len(partitions) < 4:
raise RuntimeError("Cannot shrink data partitions; SD image has fewer than four partitions")

frameos_size = frameos_image.stat().st_size
assets_size = assets_image.stat().st_size
frameos_partition = partitions[2]
assets_partition = partitions[3]
if frameos_size > frameos_partition["size"]:
raise RuntimeError(
f"{frameos_image.name} is larger than partition 3: {frameos_size} > {frameos_partition['size']}"
)
if assets_size > assets_partition["size"]:
raise RuntimeError(
f"{assets_image.name} is larger than partition 4: {assets_size} > {assets_partition['size']}"
)

frameos_start = frameos_partition["start"]
assets_start = _align_up_bytes(frameos_start + frameos_size)
output_size = assets_start + assets_size
if output_size > image_path.stat().st_size:
raise RuntimeError(f"Shrunk data partition layout would exceed {image_path.name}")

_set_mbr_partition_geometry(image_path, 3, start=frameos_start, size=frameos_size)
_set_mbr_partition_geometry(image_path, 4, start=assets_start, size=assets_size)
with image_path.open("r+b") as image:
image.truncate(output_size)
return _mbr_partitions(image_path)


def _replace_partition(image_path: Path, partitions: list[dict[str, int]], partition_number: int, source_path: Path) -> None:
if partition_number < 1 or partition_number > len(partitions):
raise RuntimeError(f"Invalid partition number {partition_number}")
Expand Down
4 changes: 2 additions & 2 deletions backend/app/tasks/frame_deploy_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
icon = "🔷"

QUICKJS_ARCHIVE_URL = "https://archive.frameos.net/source/vendor/quickjs-{version}.tar.xz"
DEFAULT_QUICKJS_VERSION = "2025-04-26"
DEFAULT_QUICKJS_SHA256 = "2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf"
DEFAULT_QUICKJS_VERSION = "2026-06-04"
DEFAULT_QUICKJS_SHA256 = "b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a"

APT_PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+.-]*$")
RPIOS_SUDO_SECURITY_UPDATE_URL = "https://www.raspberrypi.com/news/a-security-update-for-raspberry-pi-os/"
Expand Down
10 changes: 5 additions & 5 deletions backend/app/tasks/tests/deploy_ssh_target/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ RUN set -eux; \
touch /boot/config.txt; \
sed -ri 's/^#?PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config; \
sed -ri 's/^#?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config; \
wget -q -O /tmp/quickjs.tar.xz https://bellard.org/quickjs/quickjs-2025-04-26.tar.xz; \
echo "2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf /tmp/quickjs.tar.xz" | sha256sum -c -; \
wget -q -O /tmp/quickjs.tar.xz https://bellard.org/quickjs/quickjs-2026-06-04.tar.xz; \
echo "b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a /tmp/quickjs.tar.xz" | sha256sum -c -; \
tar -xf /tmp/quickjs.tar.xz -C /tmp; \
cp -a /tmp/quickjs-2025-04-26 /srv/frameos/vendor/quickjs/quickjs-2025-04-26; \
make -C /srv/frameos/vendor/quickjs/quickjs-2025-04-26 libquickjs.a; \
cp -a /tmp/quickjs-2026-06-04 /srv/frameos/vendor/quickjs/quickjs-2026-06-04; \
make -C /srv/frameos/vendor/quickjs/quickjs-2026-06-04 libquickjs.a; \
chown -R "${FRAMEOS_E2E_USER}:${FRAMEOS_E2E_USER}" /srv/frameos; \
rm -rf /tmp/quickjs.tar.xz /tmp/quickjs-2025-04-26; \
rm -rf /tmp/quickjs.tar.xz /tmp/quickjs-2026-06-04; \
rm -rf /var/lib/apt/lists/*

RUN set -eux; \
Expand Down
Loading
Loading