Skip to content
Open
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
4 changes: 3 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
# linux/386 is not published by the official node images and the napi
# binaries (@node-rs/crc32 via yauzl-promise) have no linux-ia32 build.
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,12 @@ references/
mnt/
tunnel/
deploy.sh

# SAP engine build chain (frontend/scripts/unicorn-wasm-patch/build.sh outputs)
frontend/scripts/unicorn-wasm-patch/unicorn-src/
frontend/scripts/unicorn-wasm-patch/build/
frontend/scripts/unicorn-wasm-patch/dist/
frontend/src/apple/sap/vendor/unicorn-dbg.*

# Local agent workspace
.video_agent/
143 changes: 101 additions & 42 deletions AGENTS.md

Large diffs are not rendered by default.

37 changes: 30 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,28 +1,51 @@
# Stage 1: Build frontend
FROM node:20-alpine AS frontend-build
# Build stages run on the build machine's native architecture (BUILDPLATFORM)
# and produce platform-independent artifacts; only the runtime layer is built
# per target platform. This keeps linux/386 builds as cheap as amd64/arm64.
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# Stage 2: Build backend
FROM node:20-alpine AS backend-build
# Produces dist/ (pure JS) plus a full node_modules used by the asset
# extraction stage below. This node_modules is NOT copied into the runtime
# image: native binaries (napi) must match the target platform, which is
# installed there per-arch instead.
FROM --platform=$BUILDPLATFORM node:20-alpine AS backend-build
RUN apk add --no-cache python3 make g++
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci
COPY backend/ ./
RUN npm run build

# Stage 3: Runtime
# Downloads the public Apple update package once (~380 MB over range
# requests), verifies the pinned digests, strips fat binaries to their x86_64
# slices, and emits ~22.5 MB of assets. Docker layer caching makes this a
# no-op on rebuilds unless the pinned digests change.
FROM --platform=$BUILDPLATFORM node:20-alpine AS sap-assets
WORKDIR /app/backend
COPY --from=backend-build /app/backend ./
ARG SAP_ASSETS_OUT=/out
RUN DATA_DIR=/tmp/sap-extract-work node --import tsx scripts/extract-sap-assets.mts ${SAP_ASSETS_OUT}

FROM node:20-alpine
RUN apk add --no-cache zip
WORKDIR /app
COPY --from=backend-build /app/backend/dist ./dist
COPY --from=backend-build /app/backend/node_modules ./node_modules
COPY --from=backend-build /app/backend/package.json ./
COPY backend/package*.json ./
# Native modules install per target platform: @node-rs/crc32 (via
# yauzl-promise) ships prebuilt napi binaries, but bufferutil (via wisp-js)
# has no linux-arm64-musl prebuild and falls back to source compilation.
# The toolchain is added and removed inside one layer, so it never bloats
# the final image.
RUN apk add --no-cache --virtual .node-build python3 make g++ \
&& npm ci --omit=dev \
&& apk del .node-build \
&& npm cache clean --force
COPY --from=frontend-build /app/frontend/dist ./public
COPY --from=sap-assets /out /opt/asspp/sap-assets
RUN mkdir -p /data/packages
EXPOSE 8080
ARG BUILD_COMMIT=unknown
Expand Down
82 changes: 82 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"bplist-parser": "^0.3.2",
"express": "^4.21.2",
"plist": "^3.1.0",
"unbzip2-stream": "^1.4.3",
"uuid": "^11.0.5",
"yauzl-promise": "^4.0.0"
},
Expand All @@ -29,6 +30,7 @@
"@types/node": "^22.13.1",
"@types/plist": "^3.0.5",
"@types/supertest": "^6.0.3",
"@types/unbzip2-stream": "^1.4.3",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@types/yauzl-promise": "^4.0.1",
Expand Down
42 changes: 42 additions & 0 deletions backend/scripts/extract-sap-assets.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Build-time SAP asset extraction entrypoint (used by the Dockerfile).
// Downloads the public Apple update package once, verifies the pinned
// digests, strips fat binaries to their x86_64 slices, and writes the four
// files into OUT_DIR so the runtime image can ship them prebaked.

import { mkdir, copyFile } from "node:fs/promises";
import path from "node:path";

const outDir = process.argv[2];

if (!outDir) {
console.error("usage: DATA_DIR=<workdir> tsx scripts/extract-sap-assets.mts <out-dir>");
process.exit(1);
}

if (!process.env.DATA_DIR) {
console.error("DATA_DIR must be set (config.ts reads it at import time)");
process.exit(1);
}

// Imported after the DATA_DIR guard: the config module captures the env at
// import time, so a default assigned here would arrive too late.
const { ensureSapAssets, readCachedAsset, SAP_ASSET_SPECS } = await import(
"../src/services/sapAssets.ts"
);

await ensureSapAssets();
await mkdir(outDir, { recursive: true });

for (const spec of SAP_ASSET_SPECS) {
const data = await readCachedAsset(spec.name);
if (!data) {
throw new Error(`asset ${spec.name} missing after extraction`);
}
await copyFile(
path.join(process.env.DATA_DIR, "sap-assets", spec.name),
path.join(outDir, spec.name),
);
console.log(`${spec.name}: ${data.length} bytes`);
}

console.log(`SAP assets prebaked into ${outDir}`);
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import packageRoutes from "./routes/packages.js";
import installRoutes from "./routes/install.js";
import settingsRoutes from "./routes/settings.js";
import bagRoutes from "./routes/bag.js";
import sapAssetRoutes from "./routes/sapAssets.js";

const app = express();

Expand All @@ -30,6 +31,7 @@ app.use("/api", packageRoutes);
app.use("/api", installRoutes);
app.use("/api", settingsRoutes);
app.use("/api", bagRoutes);
app.use("/api", sapAssetRoutes);

// Serve static frontend files
const publicDir = path.resolve(import.meta.dirname, "../public");
Expand Down
74 changes: 74 additions & 0 deletions backend/src/routes/sapAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SAP asset routes: status/prepare for the extraction job and authenticated
// downloads of the four cached Apple binaries. The files are public Apple
// content (extracted from a public software update package, digest-pinned),
// placing them in the same trust class as the bag proxy.

import { Router, Request, Response } from "express";
import zlib from "node:zlib";
import {
SAP_ASSET_SPECS,
ensureSapAssets,
readCachedAsset,
sapAssetsState,
} from "../services/sapAssets.js";

const router = Router();

router.get("/sap-assets/status", (_req: Request, res: Response) => {
res.json(sapAssetsState());
});

router.post("/sap-assets/prepare", async (_req: Request, res: Response) => {
try {
// Fire-and-observe: the caller polls /status for progress.
const preparation = ensureSapAssets();
void preparation.catch(() => undefined);
res.json(sapAssetsState());
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
});

router.get("/sap-assets/:name", async (req: Request, res: Response) => {
const name = req.params.name as string;
const spec = SAP_ASSET_SPECS.find((candidate) => candidate.name === name);
if (!spec) {
res.status(404).json({ error: "Unknown SAP asset" });
return;
}

let data = await readCachedAsset(name);
if (!data) {
try {
await ensureSapAssets();
data = await readCachedAsset(name);
} catch (error) {
res.status(503).json({
error:
error instanceof Error ? error.message : "SAP asset extraction failed",
});
return;
}
}
if (!data) {
res.status(503).json({ error: "SAP asset unavailable" });
return;
}

res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("ETag", `"${spec.strippedSha256}"`);
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
// gzip cuts the ~22.5 MiB bundle to ~14 MiB on the wire (CoreFP's obfuscated
// __TEXT compresses at ~50%, the icxs data blob at ~13% of its size).
if (req.headers["accept-encoding"]?.includes("gzip") && data.length > 65536) {
res.setHeader("Content-Encoding", "gzip");
res.setHeader("Vary", "Accept-Encoding");
res.send(zlib.gzipSync(data, { level: 9 }));
return;
}
res.send(data);
});

export default router;
Loading