diff --git a/.gitea/workflows/publish-pages.yml b/.gitea/workflows/publish-pages.yml new file mode 100644 index 0000000..31f13cf --- /dev/null +++ b/.gitea/workflows/publish-pages.yml @@ -0,0 +1,104 @@ +name: Publish Pages + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + env: + R2_ENDPOINT: https://${{ secrets.F40_PAGES_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + R2_ACCOUNT_ID: ${{ secrets.F40_PAGES_R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.F40_PAGES_R2_BUCKET }} + SITE_NAME_OVERRIDE: ${{ vars.F40_PAGES_SITE_NAME }} + BUILD_COMMAND_OVERRIDE: ${{ vars.F40_PAGES_BUILD_COMMAND }} + OUTPUT_DIR_OVERRIDE: ${{ vars.F40_PAGES_OUTPUT_DIR }} + DEFAULT_BUILD_COMMAND: ${{ vars.F40_PAGES_DEFAULT_BUILD_COMMAND }} + DEFAULT_OUTPUT_DIR: ${{ vars.F40_PAGES_DEFAULT_OUTPUT_DIR }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Resolve settings + run: | + SITE_NAME="${SITE_NAME_OVERRIDE:-${GITHUB_REPOSITORY##*/}}" + BUILD_COMMAND="${BUILD_COMMAND_OVERRIDE:-${DEFAULT_BUILD_COMMAND:-npm ci && npm run build:test-app}}" + OUTPUT_DIR="${OUTPUT_DIR_OVERRIDE:-${DEFAULT_OUTPUT_DIR:-dist}}" + + echo "SITE_NAME=$SITE_NAME" >> "$GITHUB_ENV" + echo "BUILD_COMMAND=$BUILD_COMMAND" >> "$GITHUB_ENV" + echo "OUTPUT_DIR=$OUTPUT_DIR" >> "$GITHUB_ENV" + + - name: Validate publish settings + env: + AWS_ACCESS_KEY_ID: ${{ secrets.F40_PAGES_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.F40_PAGES_R2_SECRET_ACCESS_KEY }} + run: | + missing=0 + for name in R2_ACCOUNT_ID R2_BUCKET AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do + value="$(eval "printf '%s' \"\${$name:-}\"")" + if [ -z "$value" ]; then + echo "::error::$name is required" + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + + case "$SITE_NAME" in + ""|*[!A-Za-z0-9._-]*) + echo "::error::SITE_NAME must contain only letters, numbers, '.', '_', and '-'" + exit 1 + ;; + esac + + - name: Install AWS CLI + run: | + if ! command -v aws >/dev/null 2>&1; then + python3 -m pip install --user awscli + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + + - name: Build static site + run: sh -c "$BUILD_COMMAND" + + - name: Validate build output + run: | + if [ ! -d "$OUTPUT_DIR" ]; then + echo "::error::Build output directory '$OUTPUT_DIR' does not exist" + exit 1 + fi + if [ ! -f "$OUTPUT_DIR/index.html" ]; then + echo "::warning::'$OUTPUT_DIR/index.html' does not exist; /$SITE_NAME/ will return 404" + fi + + - name: Publish to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.F40_PAGES_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.F40_PAGES_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + run: | + RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" + RELEASE="${GITHUB_SHA}-${RUN_ATTEMPT}" + PREFIX="sites/${SITE_NAME}/releases/${RELEASE}" + PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + aws s3 sync "$OUTPUT_DIR/" "s3://${R2_BUCKET}/${PREFIX}/" \ + --endpoint-url "$R2_ENDPOINT" \ + --delete \ + --cache-control "public,max-age=31536000,immutable" + + printf '{"site":"%s","release":"%s","sha":"%s","publishedAt":"%s"}\n' \ + "$SITE_NAME" "$RELEASE" "$GITHUB_SHA" "$PUBLISHED_AT" > current.json + + aws s3 cp current.json "s3://${R2_BUCKET}/sites/${SITE_NAME}/current.json" \ + --endpoint-url "$R2_ENDPOINT" \ + --content-type application/json \ + --cache-control no-store diff --git a/.gitignore b/.gitignore index f6990d8..cf84e96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .env.r2 +dist/ # Generated by `npm run build`. vr180player/*.css diff --git a/README.md b/README.md index 62e0651..2d0a2d6 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,43 @@ This builds the TypeScript player once, then serves `index.html` with Vite at a For headset testing, the page must be a secure context before the browser will expose immersive WebXR. A LAN URL such as `http://192.168.x.x:5173/` is useful for checking layout and media loading, but it will usually not show the headset's immersive VR prompt. Use an HTTPS URL with a trusted certificate, a trusted tunnel, or a deployed CDN/Pages URL for immersive testing. +## F40 Pages deploy +This repo includes a Gitea Actions workflow at `.gitea/workflows/publish-pages.yml`. It builds the test app into `dist/` and uploads it to the shared F40 Pages R2 bucket using the layout: + +```txt +sites/{site-name}/releases/{git-sha}-{run-attempt}/ +sites/{site-name}/current.json +``` + +By default, this repo uses: + +```sh +npm ci && npm run build:test-app +``` + +The `build:test-app` script compiles the player, copies the generated `vr180player/` CDN assets, copies the simplified `test-pages/` app, and optionally copies `media/` if that local directory exists. + +Required Gitea secrets: + +- `F40_PAGES_R2_ACCOUNT_ID` +- `F40_PAGES_R2_BUCKET` +- `F40_PAGES_R2_ACCESS_KEY_ID` +- `F40_PAGES_R2_SECRET_ACCESS_KEY` + +Optional Gitea variables: + +- `F40_PAGES_SITE_NAME`: defaults to the repository name. +- `F40_PAGES_BUILD_COMMAND`: defaults to `npm ci && npm run build:test-app`. +- `F40_PAGES_OUTPUT_DIR`: defaults to `dist`. + +When the middleman router serves the current release at `https://pages.f-40.com/{site-name}/`, the generated player can also be used as a CDN from: + +```html + +``` + +For stronger CDN caching, the router can expose immutable release URLs and use the `current.json` file only to resolve the latest release. + ## Development The player source is TypeScript in `src/vr180player/`. Generated JavaScript files in `vr180player/` are ignored by git so CI/CD can build and publish them from source. @@ -113,6 +150,7 @@ The player source is TypeScript in `src/vr180player/`. Generated JavaScript file npm install npm run dev npm run build +npm run build:test-app ``` Edit the TypeScript source files rather than generated JavaScript. A typical CI/CD publish step should run `npm ci`, `npm run build`, then publish `vr180player/` with its generated `.js` files and CSS. diff --git a/package.json b/package.json index 8c9bf3b..9a3d99b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "npm run build && vite --host 0.0.0.0", "build": "tsc && node scripts/copy-styles.mjs", + "build:test-app": "npm run build && node scripts/build-test-app.mjs", "check": "tsc --noEmit", "deploy:r2": "npm run build && npm run upload:r2", "test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs tests/launcher.test.mjs", diff --git a/scripts/build-test-app.mjs b/scripts/build-test-app.mjs new file mode 100644 index 0000000..8a5b72a --- /dev/null +++ b/scripts/build-test-app.mjs @@ -0,0 +1,58 @@ +import { cp, mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const distDir = join(rootDir, 'dist'); + +await rm(distDir, { force: true, recursive: true }); +await mkdir(distDir, { recursive: true }); + +await copyRequired('index.html'); +await copyRequired('poster.jpg'); +await copyRequired('test-pages'); +await copyRequired('vr180player'); +await copyOptional('media'); + +await writeFile( + join(distDir, '_headers'), + [ + '/vr180player/*', + ' Cache-Control: public, max-age=3600', + '', + '/*', + ' Cache-Control: public, max-age=300', + '' + ].join('\n') +); + +console.log(`Built test app in ${distDir}`); + +async function copyRequired(relativePath) { + const source = join(rootDir, relativePath); + const target = join(distDir, relativePath); + await cp(source, target, { recursive: true }); +} + +async function copyOptional(relativePath) { + const source = join(rootDir, relativePath); + if (!await pathExists(source)) { + console.warn(`Optional ${relativePath}/ directory not found; bundled sample media will not be included.`); + return; + } + + await cp(source, join(distDir, relativePath), { recursive: true }); +} + +async function pathExists(path) { + try { + await stat(path); + return true; + } catch (error) { + if (error?.code === 'ENOENT') { + return false; + } + + throw error; + } +}