FlatPark publishing platform: registry-driven Astro catalog, two-stage
publish pipeline, signing, CI, and docs. Apps are added via pull request.
This commit is contained in:
Komh
2026-06-20 12:52:46 +08:00
commit 847a023caa
65 changed files with 9550 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# Manual, destructive: reclaim R2 space. Delists apps whose registry/<id>/ is
# gone, prunes every ref to its current commit, re-signs, republishes, then
# deletes ONLY the pruned objects from R2 (prune-diff targeted delete — never a
# blind mirror). Kept separate from the additive daily publish.
name: delist-prune
on:
workflow_dispatch:
concurrency:
group: publish # share the publish lock so prune never races a publish
cancel-in-progress: false
permissions:
contents: read
jobs:
prune:
runs-on: ubuntu-latest
env:
OUT_DIR: ${{ github.workspace }}/out
REPO_DIR: ${{ github.workspace }}/out/repo
GNUPGHOME_DIR: ${{ github.workspace }}/.gnupg-flatpark
R2_BUCKET: ${{ vars.R2_BUCKET }}
CF_PAGES_PROJECT: ${{ vars.CF_PAGES_PROJECT }}
RCLONE_CONFIG_R2_TYPE: s3
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
RCLONE_CONFIG_R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
RCLONE_CONFIG_R2_ACL: private
RCLONE_CONFIG_R2_NO_CHECK_BUCKET: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install toolchain
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder ostree
curl -fsSL https://rclone.org/install.sh | sudo bash
- name: Import signing subkey
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
run: |
mkdir -p "$GNUPGHOME_DIR"; chmod 700 "$GNUPGHOME_DIR"
printf '%s\n' "$FLATPAK_GPG_PRIVATE_KEY" \
| GNUPGHOME="$GNUPGHOME_DIR" gpg --batch --import
- name: Pull current repo from R2
run: |
mkdir -p "$REPO_DIR"
rclone copy "r2:$R2_BUCKET" "$REPO_DIR" --fast-list
- name: Delist + prune to current commit
run: |
export RECLAIM_LIST="$OUT_DIR/reclaim-objects.txt"
./scripts/prune-and-reclaim.sh
- name: Re-sign + rebuild discovery and site
run: |
./scripts/publish-repo.sh
while IFS= read -r id; do ./scripts/gen-discovery.sh "$id"; done < <(./scripts/scan-registry.sh --ids)
./scripts/gen-site.sh $(./scripts/scan-registry.sh --ids | tr '\n' ' ')
- name: Sync new summary first, then delete orphaned objects
run: RECLAIM_LIST="$OUT_DIR/reclaim-objects.txt" ./scripts/sync-r2.sh
- name: Redeploy site
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx -y wrangler@3 pages deploy "$OUT_DIR/site" --project-name="$CF_PAGES_PROJECT" --branch=main
+100
View File
@@ -0,0 +1,100 @@
# Untrusted gate: runs on every PR, including forks. NO secrets are referenced
# here, so a malicious PR can never reach the signing key or R2/Pages creds.
# It validates, tests, builds the changed apps and uploads installable bundles;
# the real signed build+publish happens on push to main.
name: pr-checks
on:
pull_request:
permissions:
contents: read
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: site/package-lock.json
- name: Validate every registry descriptor
run: |
set -e
shopt -s nullglob
for d in registry/*/flatpark.yml; do
echo "validate $d"
node scripts/read-descriptor.mjs "$d" >/dev/null
done
- name: Install site deps
working-directory: site
run: npm ci || npm install --no-audit --no-fund
- name: Shell test suite (build steps self-skip without flatpak-builder)
run: bash tests/run-tests.sh
- name: Dead-link check (hotlinked screenshots etc.)
run: ./scripts/check-links.sh
# Build the changed apps to catch manifest breakage BEFORE merge, and upload an
# installable single-file bundle per app so the change can be test-installed
# (extra-data is not embedded — it's fetched on install — so bundles stay tiny).
# Runs for fork PRs too: fork `pull_request` runs get NO secrets and a read-only
# token, and the build is signed with a throwaway in-job key, so the real
# signing key and R2/Pages creds are never exposed. Untrusted build-commands are
# gated by GitHub's "require approval for fork PR workflows" setting — approve a
# fork run only after reviewing its manifest.
build-changed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: site/package-lock.json
- name: Install flatpak-builder
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder ostree
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
- name: Determine changed apps
id: changed
run: |
base="${{ github.event.pull_request.base.sha }}"
ids="$(./scripts/changed-apps.sh "$base" HEAD | tr '\n' ' ')"
echo "ids=$ids" >> "$GITHUB_OUTPUT"
echo "changed apps: ${ids:-<none>}"
- name: Build changed apps (throwaway signing key, no publish)
if: steps.changed.outputs.ids != ''
run: |
export OUT_DIR="$PWD/out" GNUPGHOME_DIR="$PWD/.gnupg-ci"
./scripts/gen-signing-key.sh >/dev/null
for id in ${{ steps.changed.outputs.ids }}; do
echo "::group::build $id"
./scripts/build-app.sh "$id"
echo "::endgroup::"
done
- name: Bundle changed apps into installable .flatpak files
if: steps.changed.outputs.ids != ''
run: |
. scripts/lib/common.sh
load_config "$PWD" # sets REPO_DIR=$PWD/out/repo, REGISTRY_DIR
mkdir -p out/bundles
for id in ${{ steps.changed.outputs.ids }}; do
load_app "$id" # resolves APP_BRANCH the same way build-app.sh did
flatpak build-bundle \
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo \
"$REPO_DIR" "out/bundles/$id.flatpak" "$id" "$APP_BRANCH"
done
ls -lah out/bundles
- name: Upload installable bundles
if: steps.changed.outputs.ids != ''
uses: actions/upload-artifact@v4
with:
name: flatpak-bundles-pr${{ github.event.pull_request.number }}
path: out/bundles/*.flatpak
retention-days: 14
if-no-files-found: error
+118
View File
@@ -0,0 +1,118 @@
# Trusted publish: only on push to main (merged PR) or manual dispatch. This is
# the ONLY workflow that touches the signing key, R2, and Pages. It builds just
# the changed apps into the authoritative repo pulled from R2, re-signs, and
# republishes the repo + site with an atomic cutover.
name: publish
on:
push:
branches: [main]
workflow_dispatch:
inputs:
rebuild_all:
description: Rebuild every app (ignore the changed-apps diff)
type: boolean
default: false
# Serialize: never let two publishes race on the repo.
concurrency:
group: publish
cancel-in-progress: false
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
env:
OUT_DIR: ${{ github.workspace }}/out
REPO_DIR: ${{ github.workspace }}/out/repo
GNUPGHOME_DIR: ${{ github.workspace }}/.gnupg-flatpark
R2_BUCKET: ${{ vars.R2_BUCKET }}
CF_PAGES_PROJECT: ${{ vars.CF_PAGES_PROJECT }}
# rclone S3 remote "r2" — the name must be a single token (no _/-), since
# rclone derives it from the env var up to the first underscore.
RCLONE_CONFIG_R2_TYPE: s3
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
RCLONE_CONFIG_R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
RCLONE_CONFIG_R2_ACL: private
RCLONE_CONFIG_R2_NO_CHECK_BUCKET: "true"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: site/package-lock.json
- name: Install toolchain
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder ostree
curl -fsSL https://rclone.org/install.sh | sudo bash
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
- name: Import signing subkey (master never reaches CI)
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
run: |
mkdir -p "$GNUPGHOME_DIR"; chmod 700 "$GNUPGHOME_DIR"
printf '%s\n' "$FLATPAK_GPG_PRIVATE_KEY" \
| GNUPGHOME="$GNUPGHOME_DIR" gpg --batch --import
GNUPGHOME="$GNUPGHOME_DIR" gpg --list-secret-keys
- name: Pull current repo from R2 (incremental base)
run: |
mkdir -p "$REPO_DIR"
rclone copy "r2:$R2_BUCKET" "$REPO_DIR" --fast-list || true
- name: Decide which apps to build
id: apps
env:
REBUILD_ALL: ${{ github.event_name == 'workflow_dispatch' && inputs.rebuild_all }}
BEFORE: ${{ github.event.before }}
run: |
if [ "$REBUILD_ALL" = "true" ]; then
ids="$(./scripts/scan-registry.sh --ids | tr '\n' ' ')" # full rebuild
elif [ -d "$REPO_DIR/objects" ] && git rev-parse --verify -q "${BEFORE}^{commit}" >/dev/null 2>&1; then
ids="$(./scripts/changed-apps.sh "$BEFORE" "$GITHUB_SHA" | tr '\n' ' ')"
else
ids="$(./scripts/scan-registry.sh --ids | tr '\n' ' ')" # cold start / unknown base
fi
echo "ids=$ids" >> "$GITHUB_OUTPUT"
echo "building: ${ids:-<none>}"
- name: Build changed apps into the repo
if: steps.apps.outputs.ids != ''
env:
IDS: ${{ steps.apps.outputs.ids }}
run: |
for id in $IDS; do
echo "::group::build $id"; ./scripts/build-app.sh "$id"; echo "::endgroup::"
done
- name: Re-sign summary + write discovery files
run: |
./scripts/publish-repo.sh
while IFS= read -r id; do ./scripts/gen-discovery.sh "$id"; done < <(./scripts/scan-registry.sh --ids)
- name: Build the site (full catalog)
run: ./scripts/gen-site.sh $(./scripts/scan-registry.sh --ids | tr '\n' ' ')
- name: Sync repo to R2 (objects first, summary last)
run: ./scripts/sync-r2.sh
- name: Deploy site to Cloudflare Pages (atomic)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx -y wrangler@3 pages deploy "$OUT_DIR/site" \
--project-name="$CF_PAGES_PROJECT" --branch=main
echo "### Deployed" >> "$GITHUB_STEP_SUMMARY"
echo "- Site: https://flatpark.org" >> "$GITHUB_STEP_SUMMARY"
echo "- Repo: https://dl.flatpark.org/flatpark.flatpakrepo" >> "$GITHUB_STEP_SUMMARY"
+47
View File
@@ -0,0 +1,47 @@
# Scheduled upstream-update detection. Runs each app's update resolver, recomputes
# the extra-data pins, and opens a PR when anything moved. No signing key or R2
# creds here — it only reads public URLs and opens a PR; a human merges it, which
# triggers publish.yml.
name: update-check
on:
schedule:
- cron: "17 6 * * *" # daily, 06:17 UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Recompute pins from upstream
run: |
ids="$(./scripts/check-updates.sh | tr '\n' ' ')"
echo "changed=$ids" >> "$GITHUB_ENV"
echo "changed apps: ${ids:-<none>}"
- name: Open / update PR if pins changed
env:
GH_TOKEN: ${{ github.token }}
run: |
if git diff --quiet -- registry/; then
echo "no pin changes"; exit 0
fi
branch=auto/update-pins
git config user.name "flatpark-bot"
git config user.email "[email protected]"
git checkout -B "$branch"
git add registry/
git commit -m "chore(update): refresh extra-data pins (${changed:-apps})"
git push -f origin "$branch"
body="$(printf 'Automated pin refresh for: %s\n\nMerging triggers a rebuild + publish of the changed app(s).' "${changed:-(see diff)}")"
if ! gh pr view "$branch" >/dev/null 2>&1; then
gh pr create --base main --head "$branch" \
--title "Update extra-data pins" --body "$body"
fi
+13
View File
@@ -0,0 +1,13 @@
out/
.gnupg-flatpark/
.gnupg-ci/
.vscode/
*.log
# Astro site: deps, build output, and generated registry data
site/node_modules/
site/dist/
site/.astro/
site/public/catalog.json
site/public/apps/
site/public/icons/
+144
View File
@@ -0,0 +1,144 @@
# Contributing an app to FlatPark
Any app with a public download is welcome — FlatPark does not host builds. As
long as the app ships an official installer or prebuilt archive at a stable,
public release URL (extra-data style), it can be added here. FlatPark fetches it
at build, pins it, and signs the result.
## Add an app
Create one directory under `registry/` named exactly for the app id:
```text
registry/com.example.App/
flatpark.yml # the descriptor (below)
com.example.App.yml # the Flatpak manifest
com.example.App.metainfo.xml
com.example.App.svg
resolve-update.sh # optional: upstream update resolver
```
Then validate and build locally:
```sh
node scripts/read-descriptor.mjs registry/com.example.App/flatpark.yml
./scripts/publish.sh --verify com.example.App
```
Open a PR. `pr-checks` validates the descriptor, runs the test suite, checks for
dead links, and (for same-repo PRs) builds the app. On merge, `publish` builds
and publishes it.
## `flatpark.yml` schema
```yaml
id: com.example.App # required — must match the directory name
name: Example App # required
summary: One-line description # required
website: https://example.com/ # optional
source_url: https://github.com/you/packaging # optional
build:
manifest: com.example.App.yml # required — relative to this directory
branch: stable # optional (default: stable)
mode: extra-data # packaging mode (internal label)
catalog: # optional — drives the catalog page
category: Productivity
tags:
- Example
- Demo
update: # optional — enables auto pin-bump PRs
command: ./resolve-update.sh
policy: # optional — informational
proprietary: true
extra_data_first: true
dangerous_permissions: []
```
Only `id`, `name`, `summary`, and `build.manifest` are required.
## Auto-updating (optional)
Version checking is **always a script** — there are no declarative checker types
to learn. Point `update.command` at a `resolve-update.sh` that figures out the
current release however it likes (a JSON/HTML endpoint, the GitHub API, a fixed
URL, whatever) and prints this JSON to stdout (logs go to stderr):
```json
{
"version": "1.2.3",
"releaseDate": "2026-06-19",
"sources": [
{ "filename": "installer.sh", "url": "https://example.com/installer-1.2.3.sh" }
]
}
```
The script does **no hashing** — it just resolves the version and the real
download URL(s). FlatPark downloads each source, computes `sha256`/`size`, and
rewrites the manifest's managed block (mark it with these comments so FlatPark
knows what to rewrite):
```yaml
# BEGIN MANAGED EXTRA-DATA
- type: extra-data
filename: installer.sh
only-arches:
- x86_64
url: https://example.com/installer-1.2.3.sh
sha256: <computed by FlatPark>
size: <computed by FlatPark>
# END MANAGED EXTRA-DATA
```
**Where the version lives:** in the AppStream metainfo `<releases>`, not in
extra-data (Flatpak has no version field there). The latest `<release version>`
is the comparison anchor: each day `update-check` runs your resolver, and only
when its `version` differs from the metainfo does it download, re-pin, prepend a
new `<release>`, and open a PR. A maintainer merges it, which rebuilds and
republishes just that app.
### Resolver templates
GitHub releases (pick the right asset):
```sh
#!/usr/bin/env bash
set -euo pipefail
repo="owner/name"
rel="$(curl -fsSL ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} \
"https://api.github.com/repos/$repo/releases/latest")"
version="$(jq -r '.tag_name | ltrimstr("v")' <<<"$rel")"
url="$(jq -r '.assets[]|select(.name|test("x86_64.*\\.AppImage$")).browser_download_url' <<<"$rel")"
date="$(jq -r '.published_at' <<<"$rel" | cut -c1-10)"
jq -n --arg v "$version" --arg d "$date" --arg u "$url" \
'{version:$v,releaseDate:$d,sources:[{filename:"app.AppImage",url:$u}]}'
```
A vendor JSON endpoint (version in one field, URL in another):
```sh
#!/usr/bin/env bash
set -euo pipefail
meta="$(curl -fsSL https://vendor.example/latest.json)"
version="$(jq -r '.version' <<<"$meta")"
url="$(jq -r '.assets[]|select(.name|test("linux-x86_64\\.deb$")).url' <<<"$meta")"
date="$(jq -r '.published_at // ""' <<<"$meta" | cut -c1-10)"
jq -n --arg v "$version" --arg d "$date" --arg u "$url" \
'{version:$v,releaseDate:$d,sources:[{filename:"app.deb",url:$u}]}'
```
A fixed URL that simply embeds the version:
```sh
#!/usr/bin/env bash
set -euo pipefail
version="$(curl -fsSL https://vendor.example/latest.txt)"
jq -n --arg v "$version" \
'{version:$v,sources:[{filename:"app.bin",url:("https://vendor.example/app-"+$v+".bin")}]}'
```
## Sandbox & permissions
Prefer the tightest `finish-args` that still work. Avoid `--filesystem=home` and
other broad grants; FlatPark surfaces permissions on each app's detail page, and
broad grants will be questioned in review.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 FlatPark
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+50
View File
@@ -0,0 +1,50 @@
# Yet another Flatpak hub
FlatPark is a Flatpak hub for apps that ship as a definitive download, built to
be driven by AI agents.
## Goals
- **AI-driven by design.** Onboarding, review, publishing, upgrading, and
cleanup are all meant to be driven by AI agents. Leaning on agents to do this
work efficiently is a primary goal, not an afterthought.
- **One runtime, always latest.** Every hosted app is continuously upgraded and
tested against the newest runtime, so you only ever need a single, latest copy
of the runtime installed.
- **extra-data only, no build hosting.** FlatPark hosts only extra-data apps: it
downloads official releases and repackages them — it never builds from source.
That keeps installing and updating the apps you need in one place, while
Flatpak keeps them sandboxed and out of your home directory.
- **Open to vibe-coded apps, with guardrails.** Apps built with AI ("vibe
coding") are welcome, under clear rules enforced by AI review that weighs
development history and app quality — and with an explicit de-listing process.
## Install
```sh
flatpak --user remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo
flatpak --user install flatpark <app-id>
```
`--user` installs into your home (no admin); drop it from both commands for a
system-wide install. Browse the catalog at <https://flatpark.org>.
## Add an app
Onboarding is meant to run through an AI agent. Open this repo in your agent
(e.g. Claude Code) and prompt it:
> I want to publish **\<app or upstream URL\>** to FlatPark. Read
> `CONTRIBUTING.md` and the existing PRs, then write and validate the manifest
> under `registry/<app-id>/`.
It writes the `flatpark.yml` descriptor and Flatpak manifest, validates and
test-builds them, and opens a PR. If it can't get there from the rules and the
existing PRs, open an issue — letting an agent submit, test, and PR end-to-end
is the goal.
## License
FlatPark's own code is [MIT](LICENSE). It does **not** license the packaged
applications themselves — those remain their vendors' property and are fetched
from official sources at install time.
+21
View File
@@ -0,0 +1,21 @@
# FlatPark publish configuration (Phase 0, local-first).
# Every value is env-overridable: `OUT_DIR=/tmp/x ./scripts/publish.sh`.
: "${REPO_TITLE:=FlatPark}"
: "${REPO_HOMEPAGE:=https://flatpark.org}"
: "${REPO_COMMENT:=Yet another Flatpak hub — AI-assisted welcome.}"
: "${REPO_URL:=https://dl.flatpark.org/}"
: "${REPO_FILE_URL:=${REPO_URL%/}/flatpark.flatpakrepo}"
: "${RUNTIME_REPO_URL:=https://dl.flathub.org/repo/flathub.flatpakrepo}"
: "${REMOTE_NAME:=flatpark}"
: "${RUNTIME_REMOTE_NAME:=flathub}"
: "${VERIFY_REMOTE_NAME:=$REMOTE_NAME}"
: "${REGISTRY_DIR:=$ROOT/registry}"
: "${GNUPGHOME_DIR:=$ROOT/.gnupg-flatpark}"
: "${KEY_NAME:=FlatPark Repository Signing Key}"
: "${KEY_EMAIL:[email protected]}"
: "${OUT_DIR:=$ROOT/out}"
: "${REPO_DIR:=$OUT_DIR/repo}"
: "${PAGES_DIR:=$OUT_DIR/site}"
: "${PUBKEY_FILE:=$OUT_DIR/flatpark.pub.asc}"
+5
View File
@@ -0,0 +1,5 @@
# App registry
One directory per app, named by its Flatpak app ID, each containing a single
`flatpark.yml` descriptor. Apps are added and updated through pull requests —
see [CONTRIBUTING.md](../CONTRIBUTING.md).
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
load_app "${1:?usage: build-app.sh <app-id>}"
need flatpak; need flatpak-builder; need gpg
export GNUPGHOME="$GNUPGHOME_DIR"
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
fpr="$(gpg --list-keys --with-colons "$KEY_EMAIL" | awk -F: '/^fpr:/{print $10; exit}')"
[ -n "$fpr" ] || die "no signing key (run gen-signing-key.sh)"
mkdir -p "$OUT_DIR"
build_dir="$OUT_DIR/build-$APP_ID"
state_dir="$OUT_DIR/flatpak-builder-state"
args=(--force-clean --repo="$REPO_DIR" --default-branch="$APP_BRANCH"
--state-dir="$state_dir"
--gpg-sign="$fpr" --gpg-homedir="$GNUPGHOME_DIR")
[ -e /dev/fuse ] || args+=(--disable-rofiles-fuse)
if [ -n "${RUNTIME_REPO_URL:-}" ]; then
flatpak --user remote-add --if-not-exists --from "$RUNTIME_REMOTE_NAME" "$RUNTIME_REPO_URL" || true
args+=(--install-deps-from="$RUNTIME_REMOTE_NAME" --user)
fi
( cd "$APP_SRC" && flatpak-builder "${args[@]}" "$build_dir" "$MANIFEST" )
log "built $APP_ID into $REPO_DIR"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Print the app ids whose registry/<id>/ directory changed between two git refs,
# limited to apps that still exist (a deleted app is handled by prune, not build).
# Usage: changed-apps.sh <base-ref> [head-ref]
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need git
base="${1:?usage: changed-apps.sh <base-ref> [head-ref]}"
head="${2:-HEAD}"
git -C "$ROOT" diff --name-only "$base" "$head" -- registry/ \
| sed -nE 's#^registry/([^/]+)/.*#\1#p' \
| sort -u \
| while IFS= read -r id; do
[ -f "$REGISTRY_DIR/$id/flatpark.yml" ] && printf '%s\n' "$id"
done
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Regenerate app data, then batch-check every external URL the site hotlinks.
# Exits non-zero if any link is broken. Usage:
# scripts/check-links.sh [--json] [app-id ...]
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need node
: "${SITE_DIR:=$ROOT/site}"
json=""
appids=()
for a in "$@"; do
case "$a" in
--json) json="--json" ;;
*) appids+=("$a") ;;
esac
done
"$ROOT/scripts/gen-apps-json.sh" ${appids[@]+"${appids[@]}"}
if [ ! -d "$SITE_DIR/node_modules" ]; then
need npm
( cd "$SITE_DIR" && npm install --no-audit --no-fund )
fi
( cd "$SITE_DIR" && node tools/enrich.mjs )
( cd "$SITE_DIR" && node tools/check-links.mjs $json )
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Run each app's update resolver, recompute its extra-data pins + metainfo
# release, and rewrite them in place. Prints the ids of apps that changed (one
# per line) — the update workflow turns those into a PR.
# Does NOT commit or open a PR itself.
#
# Usage: check-updates.sh [app-id ...] (default: every app with an update cmd)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need node
apps=()
while IFS= read -r id; do apps+=("$id"); done < <("$ROOT/scripts/scan-registry.sh" --ids "$@")
[ "${#apps[@]}" -gt 0 ] || die "registry scan returned no apps"
changed=()
for id in "${apps[@]}"; do
load_app "$id"
[ -n "${APP_UPDATE_COMMAND:-}" ] || { log "$id: no update command, skipping"; continue; }
[ -f "$MANIFEST" ] || { warn "$id: manifest not found ($MANIFEST)"; continue; }
log "$id: resolving via $APP_UPDATE_COMMAND"
if ! resolver_json="$( cd "$APP_SRC" && eval "$APP_UPDATE_COMMAND" )"; then
warn "$id: resolver failed"; continue
fi
metainfo="$APP_SRC/$APP_ID.metainfo.xml"
set +e
version="$(printf '%s' "$resolver_json" | node "$ROOT/scripts/update-pins.mjs" "$MANIFEST" "$metainfo")"
rc=$?
set -e
case "$rc" in
0) log "$id: pins updated -> ${version:-?}"; changed+=("$id") ;;
10) log "$id: up to date" ;;
*) warn "$id: update-pins failed (exit $rc)" ;;
esac
done
printf '%s\n' "${changed[@]}"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Print the armored SIGNING SUBKEY secret for the CI signing secret
# (the FLATPAK_GPG_PRIVATE_KEY GitHub Actions secret).
#
# It exports only the subkey secret — the master secret is replaced by a stub,
# so the long-term identity never leaves your machine. The keyring read is
# GNUPGHOME_DIR (the local .gnupg-flatpark by default).
#
# ./scripts/export-ci-subkey.sh | gh secret set FLATPAK_GPG_PRIVATE_KEY
#
# Requires the key to be passphrase-less (the default from gen-signing-key.sh);
# CI signs non-interactively.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need gpg
export GNUPGHOME="$GNUPGHOME_DIR"
fpr="$(gpg --list-keys --with-colons "$KEY_EMAIL" | awk -F: '/^fpr:/{print $10; exit}')"
[ -n "$fpr" ] || die "no signing key in $GNUPGHOME_DIR (run gen-signing-key.sh)"
# export-secret-subkeys exports every subkey secret with the master stubbed out
# (no `!`, which would force just the stubbed primary and drop the subkey).
out="$(gpg --batch --pinentry-mode loopback --passphrase "${KEY_PASSPHRASE:-}" \
--armor --export-secret-subkeys "$fpr")"
printf '%s' "$out" | grep -q "PGP PRIVATE KEY" || die "subkey export produced no secret material"
printf '%s\n' "$out"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
: "${SITE_DIR:=$ROOT/site}"
: "${DATA_DIR:=$SITE_DIR/public}"
: "${CATALOG_FILE:=$DATA_DIR/catalog.json}"
: "${APPS_DATA_DIR:=$DATA_DIR/apps}"
: "${ICONS_DIR:=$DATA_DIR/icons}"
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/\\r}"
s="${s//$'\n'/\\n}"
printf '%s' "$s"
}
asset_name() {
printf '%s' "$1" | tr -cs 'A-Za-z0-9._-' '-'
}
icon_url_for_app() {
local icon="$APP_ICON"
if [ -z "$icon" ] && [ -f "$APP_SRC/$APP_ID.svg" ]; then
icon="$APP_SRC/$APP_ID.svg"
fi
if [ -z "$icon" ] && [ -f "$APP_SRC/$APP_ID.png" ]; then
icon="$APP_SRC/$APP_ID.png"
fi
if [ -n "$icon" ] && [ -f "$icon" ]; then
local ext="${icon##*.}" name
name="$(asset_name "$APP_ID").$ext"
cp "$icon" "$ICONS_DIR/$name"
printf '/icons/%s' "$name"
fi
}
tags_json() {
local raw t out=""
IFS=',' read -ra raw <<< "$APP_TAGS"
for t in "${raw[@]}"; do
t="${t#"${t%%[![:space:]]*}"}"
t="${t%"${t##*[![:space:]]}"}"
[ -n "$t" ] || continue
out="$out${out:+, }\"$(json_escape "$t")\""
done
printf '[%s]' "$out"
}
requested_apps=("$@")
apps=()
while IFS= read -r app_id; do
apps+=("$app_id")
done < <("$ROOT/scripts/scan-registry.sh" --ids "${requested_apps[@]}")
[ "${#apps[@]}" -gt 0 ] || die "registry scan returned no apps"
mkdir -p "$(dirname "$CATALOG_FILE")" "$APPS_DATA_DIR" "$ICONS_DIR"
# A fresh per-app set each run so removed apps do not linger.
rm -f "$APPS_DATA_DIR"/*.json 2>/dev/null || true
remote_cmd="flatpak --user remote-add --if-not-exists $REMOTE_NAME $REPO_FILE_URL"
# --- light catalog.json (drives the index grid + client-side search) ---
{
printf '{\n'
printf ' "repo": {\n'
printf ' "title": "%s",\n' "$(json_escape "$REPO_TITLE")"
printf ' "comment": "%s",\n' "$(json_escape "$REPO_COMMENT")"
printf ' "homepage": "%s",\n' "$(json_escape "$REPO_HOMEPAGE")"
printf ' "remoteName": "%s",\n' "$(json_escape "$REMOTE_NAME")"
printf ' "remoteUrl": "%s",\n' "$(json_escape "$REPO_URL")"
printf ' "repoFileUrl": "%s",\n' "$(json_escape "$REPO_FILE_URL")"
printf ' "runtimeRemoteName": "%s",\n' "$(json_escape "$RUNTIME_REMOTE_NAME")"
printf ' "runtimeRepoUrl": "%s",\n' "$(json_escape "$RUNTIME_REPO_URL")"
printf ' "remoteCmd": "%s"\n' "$(json_escape "$remote_cmd")"
printf ' },\n'
printf ' "apps": [\n'
first=1
for app_id in "${apps[@]}"; do
load_app "$app_id"
icon="$(icon_url_for_app)"
[ "$first" = "1" ] || printf ',\n'
first=0
printf ' {\n'
printf ' "id": "%s",\n' "$(json_escape "$APP_ID")"
printf ' "name": "%s",\n' "$(json_escape "$APP_NAME")"
printf ' "summary": "%s",\n' "$(json_escape "$APP_SUMMARY")"
printf ' "category": "%s",\n' "$(json_escape "$APP_CATEGORY")"
printf ' "tags": %s,\n' "$(tags_json)"
printf ' "updateMode": "%s",\n' "$(json_escape "$UPDATE_MODE")"
if [ -n "$icon" ]; then
printf ' "icon": "%s"\n' "$(json_escape "$icon")"
else
printf ' "icon": null\n'
fi
printf ' }'
done
printf '\n ]\n'
printf '}\n'
} > "$CATALOG_FILE"
# --- base per-app apps/<id>.json (enrichment fills the rest) ---
for app_id in "${apps[@]}"; do
load_app "$app_id"
icon="$(icon_url_for_app)"
install_cmd="flatpak --user install $REMOTE_NAME $APP_ID"
{
printf '{\n'
printf ' "id": "%s",\n' "$(json_escape "$APP_ID")"
printf ' "name": "%s",\n' "$(json_escape "$APP_NAME")"
printf ' "summary": "%s",\n' "$(json_escape "$APP_SUMMARY")"
printf ' "branch": "%s",\n' "$(json_escape "$APP_BRANCH")"
printf ' "category": "%s",\n' "$(json_escape "$APP_CATEGORY")"
printf ' "tags": %s,\n' "$(tags_json)"
printf ' "updateMode": "%s",\n' "$(json_escape "$UPDATE_MODE")"
if [ -n "$icon" ]; then
printf ' "icon": "%s",\n' "$(json_escape "$icon")"
else
printf ' "icon": null,\n'
fi
printf ' "refUrl": "%s",\n' "$(json_escape "$APP_REF_URL")"
printf ' "installCmd": "%s",\n' "$(json_escape "$install_cmd")"
printf ' "remoteCmd": "%s",\n' "$(json_escape "$remote_cmd")"
printf ' "website": "%s",\n' "$(json_escape "$APP_WEBSITE")"
printf ' "sourceUrl": "%s",\n' "$(json_escape "$APP_SOURCE_URL")"
printf ' "_srcDir": "%s",\n' "$(json_escape "$APP_SRC")"
printf ' "_manifest": "%s"\n' "$(json_escape "$MANIFEST")"
printf '}\n'
} > "$APPS_DATA_DIR/$APP_ID.json"
done
log "wrote $CATALOG_FILE + ${#apps[@]} app file(s) -> $APPS_DATA_DIR"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
load_app "${1:?usage: gen-discovery.sh <app-id>}"
need gpg; need base64
export GNUPGHOME="$GNUPGHOME_DIR"
mkdir -p "$OUT_DIR" "$REPO_DIR"
gpgkey_b64="$(gpg --export "$KEY_EMAIL" | base64 -w0)"
[ -n "$gpgkey_b64" ] || die "no exportable key (run gen-signing-key.sh first)"
repo_file="$REPO_DIR/flatpark.flatpakrepo"
ref_file="$REPO_DIR/$APP_ID.flatpakref"
cat > "$repo_file" <<EOF
[Flatpak Repo]
Title=$REPO_TITLE
Url=$REPO_URL
Homepage=$REPO_HOMEPAGE
Comment=$REPO_COMMENT
GPGKey=$gpgkey_b64
EOF
cat > "$ref_file" <<EOF
[Flatpak Ref]
Name=$APP_ID
Branch=$APP_BRANCH
Url=$REPO_URL
RuntimeRepo=$RUNTIME_REPO_URL
GPGKey=$gpgkey_b64
IsRuntime=false
EOF
cp "$repo_file" "$OUT_DIR/flatpark.flatpakrepo"
cp "$ref_file" "$OUT_DIR/$APP_ID.flatpakref"
log "wrote flatpark.flatpakrepo and $APP_ID.flatpakref"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need gpg
mkdir -p "$GNUPGHOME_DIR" "$OUT_DIR"
chmod 700 "$GNUPGHOME_DIR"
export GNUPGHOME="$GNUPGHOME_DIR"
# Repo signing key structure:
# master = Certify-only (the long-term identity, the root of trust; in
# production it is generated once, backed up offline, and never put
# in CI).
# subkey = Sign-only (the operational key that actually signs the OSTree
# repo; only its secret is shipped to CI via export-ci-subkey.sh).
# The signers pass the *master* fingerprint to gpg, which auto-selects the
# signing subkey (the master has no sign capability). KEY_PASSPHRASE is empty by
# default so local/CI signing is non-interactive; the offline master backup is
# protected by encrypting the backup file itself (see the launch checklist).
if ! gpg --list-keys "$KEY_EMAIL" >/dev/null 2>&1; then
log "generating signing key (Certify master + signing subkey) for $KEY_EMAIL"
gpg --batch --pinentry-mode loopback --passphrase "${KEY_PASSPHRASE:-}" \
--quick-generate-key "$KEY_NAME <$KEY_EMAIL>" rsa4096 cert never
master="$(gpg --list-keys --with-colons "$KEY_EMAIL" | awk -F: '/^fpr:/{print $10; exit}')"
[ -n "$master" ] || die "failed to obtain master fingerprint"
gpg --batch --pinentry-mode loopback --passphrase "${KEY_PASSPHRASE:-}" \
--quick-add-key "$master" rsa4096 sign never
fi
fpr="$(gpg --list-keys --with-colons "$KEY_EMAIL" | awk -F: '/^fpr:/{print $10; exit}')"
[ -n "$fpr" ] || die "failed to obtain key fingerprint"
gpg --armor --export "$KEY_EMAIL" > "$PUBKEY_FILE"
log "public key -> $PUBKEY_FILE"
printf '%s\n' "$fpr"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need node
need npm
: "${SITE_DIR:=$ROOT/site}"
# 1. Registry -> catalog.json + base apps/<id>.json (+ icons).
"$ROOT/scripts/gen-apps-json.sh" "$@"
# 2. Install site deps once (enrichment + build both need them).
if [ ! -d "$SITE_DIR/node_modules" ]; then
log "installing site dependencies"
( cd "$SITE_DIR" && npm install --no-audit --no-fund )
fi
# 3. Enrich each app file from the developer repo (manifest/metainfo/flatpark.yml).
( cd "$SITE_DIR" && node tools/enrich.mjs )
# 4. Build the static site into PAGES_DIR.
log "building site -> $PAGES_DIR"
( cd "$SITE_DIR" && SITE_OUT_DIR="$PAGES_DIR" npm run build )
# Keep R2 lean: publish only what must be self-hosted. Detail pages are
# pre-rendered, so the per-app JSON is build-time only — strip it from the
# output. catalog.json stays (drives client-side search). Screenshots are
# hotlinked from upstream, never stored here.
rm -f "$PAGES_DIR/apps/"*.json
# Astro's content layer leaves empty module stubs in the output root; they are
# never linked from any page, so drop them from the published tree.
rm -f "$PAGES_DIR/content-assets.mjs" "$PAGES_DIR/content-modules.mjs"
log "wrote $PAGES_DIR"
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Shared helpers for FlatPark publish tooling.
set -euo pipefail
log() { printf '[flatpark] %s\n' "$*" >&2; }
warn() { printf '[flatpark] WARN: %s\n' "$*" >&2; }
die() { printf '[flatpark] ERROR: %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; }
load_config() {
ROOT="$1"
local conf="${FLATPARK_CONF:-$ROOT/config/flatpark.conf}"
if [ "${_FLATPARK_CONFIG_LOADED:-0}" = "1" ]; then
[ "${REPO_FILE_URL-}" = "${_FLATPARK_REPO_FILE_URL_DEFAULT-}" ] && unset REPO_FILE_URL
[ "${REPO_DIR-}" = "${_FLATPARK_REPO_DIR_DEFAULT-}" ] && unset REPO_DIR
[ "${PAGES_DIR-}" = "${_FLATPARK_PAGES_DIR_DEFAULT-}" ] && unset PAGES_DIR
[ "${PUBKEY_FILE-}" = "${_FLATPARK_PUBKEY_FILE_DEFAULT-}" ] && unset PUBKEY_FILE
fi
[ -f "$conf" ] || die "missing config: $conf"
# shellcheck disable=SC1090
. "$conf"
_FLATPARK_CONFIG_LOADED=1
_FLATPARK_REPO_FILE_URL_DEFAULT="${REPO_URL%/}/flatpark.flatpakrepo"
_FLATPARK_REPO_DIR_DEFAULT="$OUT_DIR/repo"
_FLATPARK_PAGES_DIR_DEFAULT="$OUT_DIR/site"
_FLATPARK_PUBKEY_FILE_DEFAULT="$OUT_DIR/flatpark.pub.asc"
local var
for var in ROOT REPO_TITLE REPO_HOMEPAGE REPO_COMMENT REPO_URL REPO_FILE_URL \
RUNTIME_REPO_URL REMOTE_NAME RUNTIME_REMOTE_NAME VERIFY_REMOTE_NAME \
REGISTRY_DIR \
GNUPGHOME_DIR KEY_NAME KEY_EMAIL OUT_DIR REPO_DIR PAGES_DIR PUBKEY_FILE \
; do
declare -g "$var=${!var}"
done
declare -g "_FLATPARK_CONFIG_LOADED=$_FLATPARK_CONFIG_LOADED"
declare -g "_FLATPARK_REPO_FILE_URL_DEFAULT=$_FLATPARK_REPO_FILE_URL_DEFAULT"
declare -g "_FLATPARK_REPO_DIR_DEFAULT=$_FLATPARK_REPO_DIR_DEFAULT"
declare -g "_FLATPARK_PAGES_DIR_DEFAULT=$_FLATPARK_PAGES_DIR_DEFAULT"
declare -g "_FLATPARK_PUBKEY_FILE_DEFAULT=$_FLATPARK_PUBKEY_FILE_DEFAULT"
}
app_record_path() {
local app_id="$1"
printf '%s/%s/flatpark.yml\n' "$REGISTRY_DIR" "$app_id"
}
load_app() {
local app_id="${1:?load_app: app id required}"
local record app_dir
record="$(app_record_path "$app_id")"
[ -f "$record" ] || die "missing app registry entry: $record"
app_dir="$(dirname "$record")"
if [ "${_FLATPARK_APP_OVERRIDE_STATE_READY:-0}" != "1" ]; then
local override_var override_marker override_value
local overridable_vars=(APP_SRC MANIFEST UPDATE_MODE APP_REF_URL APP_ICON APP_CATEGORY APP_TAGS APP_SOURCE_URL APP_WEBSITE)
for override_var in "${overridable_vars[@]}"; do
override_marker="_FLATPARK_${override_var}_HAS_OVERRIDE"
override_value="_FLATPARK_${override_var}_OVERRIDE_VALUE"
if declare -p "$override_var" >/dev/null 2>&1; then
declare -g "$override_marker=1"
declare -g "$override_value=${!override_var}"
else
declare -g "$override_marker=0"
declare -g "$override_value="
fi
done
declare -g "_FLATPARK_APP_OVERRIDE_STATE_READY=1"
fi
unset APP_ID APP_NAME APP_SUMMARY APP_BRANCH APP_SRC MANIFEST UPDATE_MODE APP_REF_URL \
APP_ICON APP_CATEGORY APP_TAGS APP_SOURCE_URL APP_WEBSITE
local override_var override_marker override_value has_override
local overridable_vars=(APP_SRC MANIFEST UPDATE_MODE APP_REF_URL APP_ICON APP_CATEGORY APP_TAGS APP_SOURCE_URL APP_WEBSITE)
for override_var in "${overridable_vars[@]}"; do
override_marker="_FLATPARK_${override_var}_HAS_OVERRIDE"
override_value="_FLATPARK_${override_var}_OVERRIDE_VALUE"
has_override="${!override_marker:-0}"
if [ "$has_override" = "1" ]; then
declare -g "$override_var=${!override_value}"
fi
done
need node
local desc
desc="$(node "$ROOT/scripts/read-descriptor.mjs" "$record")" \
|| die "failed to read descriptor: $record"
local _FP_ID _FP_NAME _FP_SUMMARY _FP_BRANCH _FP_MANIFEST _FP_MODE \
_FP_CATEGORY _FP_TAGS _FP_WEBSITE _FP_SOURCE_URL _FP_UPDATE_COMMAND
# shellcheck disable=SC1090
eval "$desc"
APP_ID="$_FP_ID"
APP_NAME="$_FP_NAME"
APP_SUMMARY="$_FP_SUMMARY"
[ "$APP_ID" = "$app_id" ] || die "registry id mismatch: wanted $app_id got $APP_ID"
# Defaults compose the descriptor values; any explicit env override (captured
# above) already won and is left untouched by these `:=` assignments.
: "${APP_SRC:=$app_dir}"
: "${MANIFEST:=$APP_SRC/$_FP_MANIFEST}"
: "${APP_BRANCH:=${_FP_BRANCH:-stable}}"
: "${UPDATE_MODE:=${_FP_MODE:-manual}}"
: "${APP_CATEGORY:=${_FP_CATEGORY:-Apps}}"
: "${APP_TAGS:=$_FP_TAGS}"
: "${APP_WEBSITE:=$_FP_WEBSITE}"
: "${APP_SOURCE_URL:=$_FP_SOURCE_URL}"
: "${APP_REF_URL:=${REPO_URL%/}/$APP_ID.flatpakref}"
: "${APP_ICON:=}"
APP_UPDATE_COMMAND="$_FP_UPDATE_COMMAND"
local var
for var in APP_ID APP_NAME APP_SUMMARY APP_BRANCH APP_SRC MANIFEST APP_REF_URL UPDATE_MODE; do
[ -n "${!var-}" ] || die "app registry entry $record did not set $var"
declare -g "$var=${!var}"
done
for var in APP_CATEGORY APP_TAGS APP_ICON APP_SOURCE_URL APP_WEBSITE APP_UPDATE_COMMAND; do
declare -g "$var=${!var}"
done
unset _FP_ID _FP_NAME _FP_SUMMARY _FP_BRANCH _FP_MANIFEST _FP_MODE \
_FP_CATEGORY _FP_TAGS _FP_WEBSITE _FP_SOURCE_URL _FP_UPDATE_COMMAND
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Reclaim repo space with prune-diff targeted deletes (never a blind mirror):
# 1. delete refs for apps no longer in the registry (delist),
# 2. prune to the current commit of every remaining ref (keep current only —
# fix forward, no rollback retention),
# 3. write the exact set of objects pruning removed to a reclaim list.
#
# The caller then re-signs the summary (publish-repo.sh) and runs sync-r2.sh
# with RECLAIM_LIST pointed at the file written here, so the new summary goes
# live BEFORE the orphaned objects are deleted from R2.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need ostree
repo="${1:-$REPO_DIR}"
[ -d "$repo/objects" ] || die "not an ostree repo: $repo"
mkdir -p "$OUT_DIR"
reclaim="${RECLAIM_LIST:-$OUT_DIR/reclaim-objects.txt}"
# 1. Delist: drop app/<id> refs whose registry entry is gone.
ostree --repo="$repo" refs | while IFS= read -r ref; do
case "$ref" in
app/*)
id="${ref#app/}"; id="${id%%/*}"
if [ ! -f "$REGISTRY_DIR/$id/flatpark.yml" ]; then
log "delist: removing ref $ref (no registry entry)"
ostree --repo="$repo" refs --delete "$ref"
fi
;;
esac
done
before="$(mktemp)"; after="$(mktemp)"
trap 'rm -f "$before" "$after"' EXIT
( cd "$repo" && find objects -type f 2>/dev/null | sort ) > "$before"
# 2. Keep only the current commit of each ref.
log "pruning to current commit (depth=0, refs-only)"
ostree --repo="$repo" prune --refs-only --depth=0 >&2
( cd "$repo" && find objects -type f 2>/dev/null | sort ) > "$after"
# 3. removed = before - after => the orphaned objects to delete from R2.
comm -23 "$before" "$after" > "$reclaim"
removed_n="$(wc -l < "$reclaim" | tr -d ' ')"
before_n="$(wc -l < "$before" | tr -d ' ')"
log "reclaim: $removed_n of $before_n object(s) pruned -> $reclaim"
printf '%s\n' "$reclaim"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need flatpak; need gpg
export GNUPGHOME="$GNUPGHOME_DIR"
repo="${1:-$REPO_DIR}"
[ -d "$repo" ] || die "repo dir not found: $repo"
fpr="$(gpg --list-keys --with-colons "$KEY_EMAIL" | awk -F: '/^fpr:/{print $10; exit}')"
[ -n "$fpr" ] || die "no signing key (run gen-signing-key.sh)"
flatpak build-update-repo --gpg-sign="$fpr" --gpg-homedir="$GNUPGHOME_DIR" "$repo"
[ -f "$repo/summary.sig" ] || die "summary.sig not produced"
log "summary refreshed and signed in $repo"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
verify=0
requested_apps=()
for arg in "$@"; do
case "$arg" in
--verify) verify=1 ;;
*) requested_apps+=("$arg") ;;
esac
done
apps=()
while IFS= read -r app_id; do
apps+=("$app_id")
done < <("$ROOT/scripts/scan-registry.sh" --ids "${requested_apps[@]}")
[ "${#apps[@]}" -gt 0 ] || die "registry scan returned no apps"
"$ROOT/scripts/gen-signing-key.sh" >/dev/null
for app_id in "${apps[@]}"; do
"$ROOT/scripts/build-app.sh" "$app_id"
done
"$ROOT/scripts/publish-repo.sh"
for app_id in "${apps[@]}"; do
"$ROOT/scripts/gen-discovery.sh" "$app_id"
done
"$ROOT/scripts/gen-site.sh" "${apps[@]}"
log "publish complete -> $OUT_DIR"
if [ "$verify" = "1" ]; then
need flatpak
remote="$VERIFY_REMOTE_NAME"
remote_url="file://$REPO_DIR"
remote_args=(--title="$REPO_TITLE" --comment="$REPO_COMMENT"
--homepage="$REPO_HOMEPAGE" --gpg-import="$PUBKEY_FILE")
if flatpak --user remotes | awk '{print $1}' | grep -qxF "$remote"; then
flatpak --user remote-modify --url="$remote_url" "${remote_args[@]}" "$remote"
else
flatpak --user remote-add "${remote_args[@]}" "$remote" "$remote_url"
fi
for app_id in "${apps[@]}"; do
flatpak --user install -y "$remote" "$app_id"
log "verify: installed $app_id from local signed repo OK"
done
fi
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// Read a FlatPark registry descriptor (flatpark.yml) and print shell
// assignments for the fields the publish pipeline needs. Output is single-quoted
// and safe to `eval` in bash.
//
// This is NOT a general YAML parser: the descriptor schema is small and
// controlled, so we extract exactly the known paths (id, name, summary,
// website, source_url, build.{manifest,branch,mode}, catalog.{category,tags},
// update.command). Keeping it dependency-free means the core bash pipeline only
// needs `node`, not an npm install.
import { readFileSync } from 'node:fs';
const file = process.argv[2];
if (!file) {
process.stderr.write('usage: read-descriptor.mjs <flatpark.yml>\n');
process.exit(2);
}
let text;
try {
text = readFileSync(file, 'utf8');
} catch (e) {
process.stderr.write(`cannot read ${file}: ${e.message || e}\n`);
process.exit(1);
}
function unquote(v) {
v = v.trim();
if (v === '' || v === '~' || v === 'null') return '';
const c = v[0];
if ((c === '"' || c === "'") && v[v.length - 1] === c) return v.slice(1, -1);
return v;
}
const d = { tags: [] };
let section = null; // current top-level block key (build / catalog / ...)
let inTags = false; // collecting catalog.tags block-list items
function topScalar(key, val) {
if (key === 'id') d.id = val;
else if (key === 'name') d.name = val;
else if (key === 'summary') d.summary = val;
else if (key === 'website') d.website = val;
else if (key === 'source_url') d.source_url = val;
}
for (const raw of text.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line.trim() || /^\s*#/.test(line)) continue;
const indent = line.length - line.replace(/^\s+/, '').length;
const s = line.trim();
if (indent === 0) {
inTags = false;
const m = s.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
if (!m) { section = null; continue; }
const [, key, val] = m;
if (val === '') section = key; // a nested block follows
else { section = null; topScalar(key, unquote(val)); }
continue;
}
if (s.startsWith('- ')) {
if (section === 'catalog' && inTags) d.tags.push(unquote(s.slice(2)));
continue;
}
const m = s.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
if (!m) continue;
const [, key, val] = m;
if (section === 'build') {
if (key === 'manifest') d.manifest = unquote(val);
else if (key === 'branch') d.branch = unquote(val);
else if (key === 'mode') d.mode = unquote(val);
} else if (section === 'catalog') {
if (key === 'category') d.category = unquote(val);
else if (key === 'tags') {
const t = val.trim();
if (t === '[]') { d.tags = []; inTags = false; }
else if (t === '') { inTags = true; } // block list items follow
else { // inline flow list: [a, b, c]
d.tags = t.replace(/^\[|\]$/g, '').split(',').map(unquote).filter(Boolean);
inTags = false;
}
}
} else if (section === 'update') {
if (key === 'command') d.update_command = unquote(val);
}
// maintainer / policy blocks are not needed by the build pipeline.
}
function required(v, name) {
if (!v) {
process.stderr.write(`descriptor ${file} missing required field: ${name}\n`);
process.exit(1);
}
return v;
}
required(d.id, 'id');
required(d.name, 'name');
required(d.summary, 'summary');
required(d.manifest, 'build.manifest');
function sq(v) {
return "'" + String(v == null ? '' : v).replace(/'/g, "'\\''") + "'";
}
const out = [
`_FP_ID=${sq(d.id)}`,
`_FP_NAME=${sq(d.name)}`,
`_FP_SUMMARY=${sq(d.summary)}`,
`_FP_BRANCH=${sq(d.branch || '')}`,
`_FP_MANIFEST=${sq(d.manifest)}`,
`_FP_MODE=${sq(d.mode || '')}`,
`_FP_CATEGORY=${sq(d.category || '')}`,
`_FP_TAGS=${sq((d.tags || []).join(', '))}`,
`_FP_WEBSITE=${sq(d.website || '')}`,
`_FP_SOURCE_URL=${sq(d.source_url || '')}`,
`_FP_UPDATE_COMMAND=${sq(d.update_command || '')}`,
].join('\n');
process.stdout.write(out + '\n');
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
ids_only=0
if [ "${1:-}" = "--ids" ]; then
ids_only=1
shift
fi
if [ "$#" -gt 0 ]; then
apps=("$@")
else
apps=()
for record in "$REGISTRY_DIR"/*/flatpark.yml; do
[ -e "$record" ] || die "no app registry entries in $REGISTRY_DIR"
apps+=("$(basename "$(dirname "$record")")")
done
fi
for app_id in "${apps[@]}"; do
load_app "$app_id"
if [ "$ids_only" = "1" ]; then
printf '%s\n' "$APP_ID"
else
printf '%s\t%s\t%s\t%s\n' "$APP_ID" "$APP_BRANCH" "$UPDATE_MODE" "$MANIFEST"
fi
done
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Publish the local OSTree repo (REPO_DIR) + discovery files to R2 with rclone.
#
# Ordering matters: content-addressed objects go up FIRST (and are cached
# forever), the mutable `summary` pointer goes up LAST, so a client never reads
# a summary that references objects not yet uploaded. With RECLAIM_LIST set, the
# orphaned objects listed there are deleted AFTER the new summary is live (see
# prune-and-reclaim.sh).
#
# rclone must have an R2 (S3) remote configured; in CI that is done with
# RCLONE_CONFIG_<REMOTE>_* env vars. Required: R2_BUCKET. Optional: R2_REMOTE
# (default r2), RECLAIM_LIST.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
need rclone
repo="${1:-$REPO_DIR}"
[ -d "$repo" ] || die "repo dir not found: $repo"
remote="${R2_REMOTE:-r2}"
bucket="${R2_BUCKET:?set R2_BUCKET (the R2 bucket name)}"
dest="$remote:$bucket"
IMMUTABLE="Cache-Control: public, max-age=31536000, immutable"
MUTABLE="Cache-Control: public, max-age=0, must-revalidate"
FLAGS=(--fast-list --transfers=16 --checkers=32)
log "sync objects -> $dest (immutable)"
# Content-addressed: same name => same bytes, so --size-only is safe and fast.
rclone copy "$repo/objects" "$dest/objects" --size-only --header-upload "$IMMUTABLE" "${FLAGS[@]}"
[ -d "$repo/deltas" ] && rclone copy "$repo/deltas" "$dest/deltas" --size-only --header-upload "$IMMUTABLE" "${FLAGS[@]}"
log "sync refs/config/discovery -> $dest (revalidate)"
rclone copy "$repo/refs" "$dest/refs" --checksum --header-upload "$MUTABLE" "${FLAGS[@]}"
rclone copy "$repo" "$dest" --max-depth 1 --checksum --header-upload "$MUTABLE" "${FLAGS[@]}" \
--include "config" --include "*.flatpakrepo" --include "*.flatpakref" --include "*.pub.asc"
log "sync summary -> $dest (LAST, revalidate)"
rclone copy "$repo" "$dest" --max-depth 1 --checksum --header-upload "$MUTABLE" "${FLAGS[@]}" \
--include "summary" --include "summary.sig" --include "summary.idx"
[ -d "$repo/summaries" ] && rclone copy "$repo/summaries" "$dest/summaries" --checksum --header-upload "$MUTABLE" "${FLAGS[@]}"
if [ -n "${RECLAIM_LIST:-}" ] && [ -s "$RECLAIM_LIST" ]; then
n="$(wc -l < "$RECLAIM_LIST")"
log "reclaim: deleting $n orphaned object(s) from $dest"
while IFS= read -r obj; do
[ -n "$obj" ] || continue
rclone deletefile "$dest/$obj" 2>/dev/null || warn "could not delete $obj (already gone?)"
done < "$RECLAIM_LIST"
fi
log "R2 sync complete -> $dest"
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env node
// Apply an update resolver's output to an app: refresh the manifest's
// "MANAGED EXTRA-DATA" pins and the metainfo <releases>.
//
// <resolver JSON on stdin> | update-pins.mjs <manifest> [metainfo]
//
// Resolver JSON: { version, releaseDate, sources: [ { filename, url } ] }
//
// The comparison anchor is the VERSION: the latest <release version="..."> in
// the metainfo is "what we have". If the resolver's version equals it, nothing
// changed (exit 10, no download). On a new version we download every source,
// recompute the extra-data sha256/size FlatPark-side, rewrite the MANAGED block,
// and prepend a <release> to the metainfo. With no version anchor (resolver
// emits no version), we fall back to per-source URL comparison.
//
// Exit 0 = changed (manifest and/or metainfo rewritten); prints the version.
// Exit 10 = nothing changed.
// Exit 1 = error.
import { readFileSync, writeFileSync, existsSync, createReadStream } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const BEGIN = '# BEGIN MANAGED EXTRA-DATA';
const END = '# END MANAGED EXTRA-DATA';
const die = (m) => { process.stderr.write(`update-pins: ${m}\n`); process.exit(1); };
const manifestPath = process.argv[2];
const metainfoPath = process.argv[3];
if (!manifestPath) die('usage: update-pins.mjs <manifest> [metainfo]');
let resolver;
try { resolver = JSON.parse(readFileSync(0, 'utf8')); }
catch (e) { die(`bad resolver JSON: ${e.message}`); }
if (!resolver || !Array.isArray(resolver.sources)) die('resolver JSON missing sources[]');
const text = readFileSync(manifestPath, 'utf8');
const lines = text.split('\n');
const bi = lines.findIndex((l) => l.includes(BEGIN));
const ei = lines.findIndex((l) => l.includes(END));
if (bi < 0 || ei < 0 || ei < bi) die('manifest has no MANAGED EXTRA-DATA block');
const baseIndent = (lines[bi].match(/^(\s*)/)[1]) || ' ';
const oldBlock = lines.slice(bi + 1, ei);
// Parse the current block: filename -> { url, sha256, size, arches }.
const current = {};
let cur = null;
for (const l of oldBlock) {
const t = l.trim();
if (t === '- type: extra-data') { cur = { arches: [] }; continue; }
if (!cur) continue;
let m;
if ((m = t.match(/^filename:\s*(.+)$/))) { cur.filename = m[1].trim(); current[cur.filename] = cur; }
else if ((m = t.match(/^url:\s*(.+)$/))) cur.url = m[1].trim();
else if ((m = t.match(/^sha256:\s*(.+)$/))) cur.sha256 = m[1].trim();
else if ((m = t.match(/^size:\s*(.+)$/))) cur.size = m[1].trim();
else if ((m = t.match(/^-\s*(\S+)$/)) && !/:/.test(t)) cur.arches.push(m[1].trim());
}
const metainfo = metainfoPath && existsSync(metainfoPath) ? readFileSync(metainfoPath, 'utf8') : null;
const firstReleaseVersion = (xml) => {
const m = xml && xml.match(/<release\b[^>]*\bversion="([^"]*)"/);
return m ? m[1] : '';
};
const knownVersion = firstReleaseVersion(metainfo);
// Version gate: cheap short-circuit when upstream hasn't moved (no downloads).
if (resolver.version && knownVersion && resolver.version === knownVersion) process.exit(10);
// A version present here means a real bump (or no anchor yet) -> re-pin fresh.
// Without a version we fall back to URL comparison and only fetch moved sources.
const versionBump = !!resolver.version;
async function pin(url) {
const hash = createHash('sha256');
let size = 0;
if (url.startsWith('file://')) {
await new Promise((res, rej) => {
const s = createReadStream(fileURLToPath(url));
s.on('data', (c) => { hash.update(c); size += c.length; });
s.on('end', res); s.on('error', rej);
});
} else {
const r = await fetch(url, { redirect: 'follow' });
if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
for await (const chunk of r.body) { hash.update(chunk); size += chunk.length; }
}
return { sha256: hash.digest('hex'), size: String(size) };
}
const fields = `${baseIndent} `;
const archItem = `${baseIndent} `;
const out = [];
for (const src of resolver.sources) {
if (!src.filename || !src.url) die('each source needs filename + url');
const prev = current[src.filename];
const arches = prev && prev.arches.length ? prev.arches : ['x86_64'];
let sha256, size;
if (!versionBump && prev && prev.url === src.url && prev.sha256 && prev.size) {
({ sha256, size } = prev); // URL unchanged and no version bump -> keep the pin
} else {
try { ({ sha256, size } = await pin(src.url)); }
catch (e) { die(e.message); }
}
out.push(`${baseIndent}- type: extra-data`);
out.push(`${fields}filename: ${src.filename}`);
out.push(`${fields}only-arches:`);
for (const a of arches) out.push(`${archItem}- ${a}`);
out.push(`${fields}url: ${src.url}`);
out.push(`${fields}sha256: ${sha256}`);
out.push(`${fields}size: ${size}`);
}
const manifestChanged = out.join('\n') !== oldBlock.join('\n');
// Prepend a <release> to the metainfo when the version moved.
let metainfoChanged = false;
let newMetainfo = metainfo;
if (metainfo && resolver.version && resolver.version !== knownVersion) {
const date = resolver.releaseDate ? ` date="${resolver.releaseDate}"` : '';
const rel = `<release version="${resolver.version}"${date} />`;
if (/<releases\b[^>]*>/.test(metainfo)) {
newMetainfo = metainfo.replace(/(<releases\b[^>]*>)/, `$1\n ${rel}`);
} else if (metainfo.includes('</component>')) {
newMetainfo = metainfo.replace('</component>', ` <releases>\n ${rel}\n </releases>\n</component>`);
}
metainfoChanged = newMetainfo !== metainfo;
}
if (!manifestChanged && !metainfoChanged) process.exit(10);
if (manifestChanged) {
writeFileSync(manifestPath, [...lines.slice(0, bi + 1), ...out, ...lines.slice(ei)].join('\n'));
}
if (metainfoChanged) writeFileSync(metainfoPath, newMetainfo);
process.stdout.write(`${resolver.version || ''}\n`);
+13
View File
@@ -0,0 +1,13 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
// outDir is overridable so the publish pipeline (and tests) can target any
// location; defaults to the repo's shared out/site.
export default defineConfig({
outDir: process.env.SITE_OUT_DIR || '../out/site',
output: 'static',
vite: {
plugins: [tailwindcss()],
},
});
+6284
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "flatpark-site",
"type": "module",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.0",
"astro": "^5.7.0",
"fast-xml-parser": "^4.5.0",
"tailwindcss": "^4.1.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/node": "^25.9.3"
}
}
+29
View File
@@ -0,0 +1,29 @@
<svg width="100%" viewBox="0 0 240 240" role="img" xmlns="http://www.w3.org/2000/svg" style="">
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Flatpark scrambled 2x2 cube mark</title>
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">An isometric 2 by 2 cube with its twelve visible cells in mixed scrambled colors.</desc>
<g transform="translate(45,45)" stroke="#26303a" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" style="fill:rgb(0, 0, 0);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<g style="fill:rgb(0, 0, 0);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<path d="M10.0 40.0 L45.0 20.0 L80.0 40.0 L45.0 60.0 Z" fill="#4cb782" style="fill:rgb(76, 183, 130);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M45.0 60.0 L80.0 40.0 L115.0 60.0 L80.0 80.0 Z" fill="#e8a33d" style="fill:rgb(232, 163, 61);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M45.0 20.0 L80.0 0.0 L115.0 20.0 L80.0 40.0 Z" fill="#e06a5a" style="fill:rgb(224, 106, 90);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M80.0 40.0 L115.0 20.0 L150.0 40.0 L115.0 60.0 Z" fill="#edd14e" style="fill:rgb(237, 209, 78);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g style="fill:rgb(0, 0, 0);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<path d="M10.0 40.0 L45.0 60.0 L45.0 101.0 L10.0 81.0 Z" fill="#638fd0" style="fill:rgb(99, 143, 208);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M10.0 81.0 L45.0 101.0 L45.0 142.0 L10.0 122.0 Z" fill="#edd14e" style="fill:rgb(237, 209, 78);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M45.0 60.0 L80.0 80.0 L80.0 121.0 L45.0 101.0 Z" fill="#e06a5a" style="fill:rgb(224, 106, 90);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M45.0 101.0 L80.0 121.0 L80.0 162.0 L45.0 142.0 Z" fill="#4cb782" style="fill:rgb(76, 183, 130);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g style="fill:rgb(0, 0, 0);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<path d="M80.0 80.0 L115.0 60.0 L115.0 101.0 L80.0 121.0 Z" fill="#edd14e" style="fill:rgb(237, 209, 78);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M80.0 121.0 L115.0 101.0 L115.0 142.0 L80.0 162.0 Z" fill="#638fd0" style="fill:rgb(99, 143, 208);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M115.0 60.0 L150.0 40.0 L150.0 81.0 L115.0 101.0 Z" fill="#4cb782" style="fill:rgb(76, 183, 130);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M115.0 101.0 L150.0 81.0 L150.0 122.0 L115.0 142.0 Z" fill="#e8a33d" style="fill:rgb(232, 163, 61);stroke:rgb(38, 48, 58);color:rgb(0, 0, 0);stroke-width:3px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

+38
View File
@@ -0,0 +1,38 @@
---
const { app } = Astro.props;
const tags = app.tags ?? [];
const fallback = (app.name.match(/[A-Za-z0-9]/g) ?? []).slice(0, 2).join('').toUpperCase();
const search = [app.id, app.name, app.summary, app.category, tags.join(' ')]
.join(' ')
.toLowerCase();
---
<a
href={`/apps/${app.id}/`}
class="flex items-center gap-4 rounded-2xl border border-line bg-surface p-4 transition hover:-translate-y-0.5 hover:border-brand/40 hover:shadow-lg"
data-app-card
data-search={search}
>
{
app.icon ? (
<img
class="h-16 w-16 shrink-0 rounded-2xl border border-line bg-canvas object-contain p-2"
src={app.icon}
alt=""
width="64"
height="64"
/>
) : (
<div class="grid h-16 w-16 shrink-0 place-items-center rounded-2xl bg-brand text-lg font-black text-white" aria-hidden="true">
{fallback}
</div>
)
}
<div class="min-w-0">
<h3 class="truncate text-base font-bold">{app.name}</h3>
<p class="line-clamp-2 text-sm text-muted">{app.summary}</p>
<div class="mt-2 flex flex-wrap gap-1.5">
<span class="chip">{app.category}</span>
</div>
</div>
</a>
+17
View File
@@ -0,0 +1,17 @@
---
const { count } = Astro.props;
const appWord = count === 1 ? 'app' : 'apps';
---
<section data-hero class="hero-collapse">
<div class="min-h-0 overflow-hidden">
<div class="mx-auto flex min-h-[52vh] max-w-3xl flex-col items-center justify-center px-5 text-center">
<h1 class="text-4xl font-extrabold tracking-tight md:text-6xl">
Yet another Flatpak hub
</h1>
<p class="mt-5 text-lg text-muted">
Currently hosting <span class="font-semibold text-ink">{count}</span> signed {appWord}.
</p>
</div>
</div>
</section>
+66
View File
@@ -0,0 +1,66 @@
---
const { rows = [], permissions = [] } = Astro.props;
const dot = {
safe: 'bg-emerald-500',
info: 'bg-slate-400',
caution: 'bg-amber-500',
warning: 'bg-red-500',
};
const order = { warning: 0, caution: 1, info: 2, safe: 3 };
const sorted = [...permissions].sort((a, b) => (order[a.level] ?? 2) - (order[b.level] ?? 2));
const sensitive = permissions.filter((p) => p.level === 'warning' || p.level === 'caution').length;
---
<div class="flex flex-col gap-5 rounded-2xl border border-line bg-surface p-5">
{
rows.length > 0 && (
<div>
<h2 class="text-base font-bold">Details</h2>
<dl class="mt-3 flex flex-col gap-3 text-sm">
{rows.map((row) => (
<div class="flex items-baseline justify-between gap-4">
<dt class="shrink-0 text-muted">{row.label}</dt>
<dd class="min-w-0 truncate text-right font-medium">
{row.href ? (
<a class="text-brand hover:underline" href={row.href}>
{row.value}
</a>
) : (
row.value
)}
</dd>
</div>
))}
</dl>
</div>
)
}
<div class={rows.length > 0 ? 'border-t border-line pt-5' : ''}>
<div class="flex items-center justify-between">
<h2 class="text-base font-bold">Permissions</h2>
{
permissions.length > 0 && (
<span class="chip">{sensitive > 0 ? `${sensitive} to review` : 'Minimal'}</span>
)
}
</div>
{
sorted.length === 0 ? (
<p class="mt-2 text-sm text-muted">No special permissions declared — fully sandboxed.</p>
) : (
<ul class="mt-3 flex flex-col gap-3">
{sorted.map((p) => (
<li class="flex gap-3">
<span class={`mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${dot[p.level] || 'bg-slate-400'}`} />
<div class="min-w-0">
<p class="text-sm font-semibold">{p.label}</p>
{p.detail ? <p class="text-xs text-muted">{p.detail}</p> : null}
</div>
</li>
))}
</ul>
)
}
</div>
</div>
+38
View File
@@ -0,0 +1,38 @@
---
const { screenshots = [] } = Astro.props;
---
{
screenshots.length > 0 && (
<div data-carousel class="min-w-0">
<div class="relative overflow-hidden rounded-2xl border border-line bg-canvas">
{screenshots.map((s, i) => (
<figure data-slide class={i === 0 ? '' : 'hidden'}>
<img
class="h-[22rem] w-full bg-canvas object-contain"
src={s.url}
alt={s.caption || ''}
loading="lazy"
data-screenshot
/>
{s.caption ? (
<figcaption class="border-t border-line bg-surface px-4 py-2 text-center text-xs text-muted">
{s.caption}
</figcaption>
) : null}
</figure>
))}
</div>
<div data-dots class={`mt-3 flex justify-center gap-2 ${screenshots.length > 1 ? '' : 'hidden'}`}>
{screenshots.map((_, i) => (
<button
type="button"
data-dot
aria-label={`Screenshot ${i + 1}`}
class={`h-2.5 w-2.5 rounded-full transition ${i === 0 ? 'bg-brand' : 'bg-line'}`}
/>
))}
</div>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
---
const { repo } = Astro.props;
---
<header class="sticky top-0 z-20 border-b border-line bg-white/85 backdrop-blur">
<div class="mx-auto flex h-16 max-w-6xl items-center gap-4 px-5">
<a href="/" class="flex shrink-0 items-center gap-2 font-extrabold tracking-tight">
<img src="/logo.svg" alt="" width="32" height="32" class="h-8 w-8" />
<span class="hidden sm:inline">{repo.title}</span>
</a>
<form class="mx-auto w-full max-w-md" action="/" role="search">
<input
class="h-10 w-full rounded-full border border-line bg-canvas px-4 text-sm outline-none focus:border-brand focus:bg-surface focus:ring-2 focus:ring-brand/20"
type="search"
name="q"
placeholder="Search apps"
aria-label="Search apps"
autocomplete="off"
data-search
/>
</form>
<a
href="/setup/"
class="inline-flex h-10 shrink-0 items-center rounded-lg bg-brand px-4 text-sm font-semibold text-white hover:bg-brand-dark"
>
Setup
</a>
</div>
</header>
+186
View File
@@ -0,0 +1,186 @@
---
import '../styles/global.css';
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>{title}</title>
</head>
<body class="min-h-screen">
<slot />
<script>
// Catalog search (index only; no-ops elsewhere) + copy buttons.
const searchInput = document.querySelector('[data-search]');
const cards = Array.from(document.querySelectorAll('[data-app-card]'));
const empty = document.querySelector('[data-empty]');
function apply() {
const q = (searchInput?.value || '').trim().toLowerCase();
let visible = 0;
for (const card of cards) {
const hay = card.dataset.search || '';
const show = q === '' || hay.includes(q);
card.hidden = !show;
if (show) visible += 1;
}
if (empty) empty.hidden = visible !== 0;
}
searchInput?.addEventListener('input', apply);
// Header search is global. On the catalog (cards present) it filters live;
// on other pages submitting navigates to the catalog with ?q=.
const searchForm = searchInput?.closest('form');
if (searchForm) {
searchForm.addEventListener('submit', (e) => {
if (cards.length) e.preventDefault();
});
}
const q = new URLSearchParams(location.search).get('q');
if (q && searchInput) searchInput.value = q;
for (const button of document.querySelectorAll('[data-copy]')) {
button.addEventListener('click', async (e) => {
e.preventDefault();
const label = button.textContent;
try {
await navigator.clipboard.writeText(button.dataset.copy || '');
button.textContent = 'Copied';
} catch {
button.textContent = 'Copy failed';
}
setTimeout(() => {
button.textContent = label;
}, 1200);
});
}
apply();
// Scope tabs (setup page; no-ops elsewhere): switch which command panel
// shows. Tabs carry data-tab, panels carry the matching data-panel.
for (const tabs of document.querySelectorAll('[data-tabs]')) {
const buttons = Array.from(tabs.querySelectorAll('[data-tab]'));
const panels = Array.from(tabs.querySelectorAll('[data-panel]'));
for (const button of buttons) {
button.addEventListener('click', () => {
const id = button.dataset.tab;
for (const b of buttons) {
const on = b === button;
b.classList.toggle('is-active', on);
b.setAttribute('aria-selected', String(on));
}
for (const p of panels) p.hidden = p.dataset.panel !== id;
});
}
}
// Screenshot carousel: one slide at a time, dots below to switch. Drop any
// slide whose hotlinked image fails so the page never shows a broken icon.
for (const carousel of document.querySelectorAll('[data-carousel]')) {
let slides = Array.from(carousel.querySelectorAll('[data-slide]'));
let dots = Array.from(carousel.querySelectorAll('[data-dot]'));
const dotsWrap = carousel.querySelector('[data-dots]');
let cur = 0;
const render = () => {
slides.forEach((s, i) => s.classList.toggle('hidden', i !== cur));
dots.forEach((d, i) => {
d.classList.toggle('bg-brand', i === cur);
d.classList.toggle('bg-line', i !== cur);
});
dotsWrap?.classList.toggle('hidden', slides.length <= 1);
};
const drop = (s) => {
const i = slides.indexOf(s);
if (i < 0) return;
s.remove();
dots[i]?.remove();
slides.splice(i, 1);
dots.splice(i, 1);
if (cur >= slides.length) cur = Math.max(0, slides.length - 1);
if (!slides.length) carousel.remove();
else render();
};
dots.forEach((d, i) => d.addEventListener('click', () => { cur = i; render(); }));
slides.forEach((s) => s.querySelector('img')?.addEventListener('error', () => drop(s)));
render();
}
// Hero folds away on the first downward scroll/swipe, revealing the
// catalog; scrolling back to the very top brings it back.
const hero = document.querySelector('[data-hero]');
if (hero) {
const collapse = () => hero.classList.add('is-collapsed');
const expand = () => {
if (window.scrollY <= 0) hero.classList.remove('is-collapsed');
};
window.addEventListener(
'wheel',
(e) => (e.deltaY > 0 ? collapse() : e.deltaY < 0 ? expand() : null),
{ passive: true },
);
window.addEventListener('keydown', (e) => {
if (['ArrowDown', 'PageDown', ' '].includes(e.key)) collapse();
else if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) expand();
});
let touchY = null;
window.addEventListener('touchstart', (e) => { touchY = e.touches[0].clientY; }, { passive: true });
window.addEventListener(
'touchmove',
(e) => {
if (touchY === null) return;
const dy = touchY - e.touches[0].clientY;
if (dy > 24) collapse();
else if (dy < -24) expand();
},
{ passive: true },
);
}
// Detail-page install split button: the main button (and the menu item)
// copy the install command; the caret toggles a menu with the download.
for (const btn of document.querySelectorAll('[data-install-copy]')) {
btn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(btn.dataset.installCopy || '');
} catch {}
const label = btn.querySelector('[data-install-label]');
if (label) {
const prev = label.textContent;
label.textContent = 'Copied!';
setTimeout(() => { label.textContent = prev; }, 1200);
}
});
}
const installRoot = document.querySelector('[data-install]');
if (installRoot) {
const toggle = installRoot.querySelector('[data-install-toggle]');
const menu = installRoot.querySelector('[data-install-menu]');
toggle?.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = menu.hidden;
menu.hidden = !willOpen;
toggle.setAttribute('aria-expanded', String(willOpen));
});
document.addEventListener('click', (e) => {
if (!installRoot.contains(e.target)) {
menu.hidden = true;
toggle?.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
menu.hidden = true;
toggle?.setAttribute('aria-expanded', 'false');
}
});
}
</script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
// Build-time data access. The data dir holds catalog.json (light, for the
// index + search) and apps/<id>.json (rich, for detail pages). It is
// env-overridable so the publish pipeline and tests can target any location.
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
export const dataDir = process.env.FLATPARK_DATA_DIR || 'public';
export function loadCatalog() {
return JSON.parse(readFileSync(join(dataDir, 'catalog.json'), 'utf8'));
}
export function loadApps() {
const dir = join(dataDir, 'apps');
return readdirSync(dir)
.filter((f) => f.endsWith('.json'))
.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')))
.sort((a, b) => a.name.localeCompare(b.name));
}
+147
View File
@@ -0,0 +1,147 @@
---
import Base from '../../layouts/Base.astro';
import Topbar from '../../components/Topbar.astro';
import Screenshots from '../../components/Screenshots.astro';
import InfoPanel from '../../components/InfoPanel.astro';
import { loadApps, loadCatalog } from '../../lib/data.mjs';
export function getStaticPaths() {
const { repo } = loadCatalog();
return loadApps().map((app) => ({ params: { id: app.id }, props: { app, repo } }));
}
const { app, repo } = Astro.props;
const fallback = (app.name.match(/[A-Za-z0-9]/g) ?? []).slice(0, 2).join('').toUpperCase();
const latest = app.releases?.[0];
const detailRows = [
app.developer && { label: 'Developer', value: app.developer },
app.license && {
label: 'License',
value: app.license.label,
href: app.license.url,
},
latest && { label: 'Version', value: `${latest.version}${latest.date ? ` · ${latest.date}` : ''}` },
app.website && { label: 'Website', value: 'Homepage', href: app.website },
app.urls?.help && { label: 'Support', value: 'Help & support', href: app.urls.help },
app.sourceUrl && { label: 'Source', value: 'Packaging repo', href: app.sourceUrl },
].filter(Boolean);
const hasShots = (app.screenshots?.length ?? 0) > 0;
---
<Base title={`${app.name} — ${repo.title}`}>
<Topbar repo={repo} />
<main>
<div class="mx-auto max-w-5xl px-5 pt-6 text-sm text-muted">
<a href="/" class="hover:text-ink">&larr; All apps</a>
</div>
<!-- header -->
<section class="mx-auto max-w-5xl px-5 pt-4">
<div class="flex flex-col gap-6 sm:flex-row sm:items-center">
{
app.icon ? (
<img
class="h-24 w-24 shrink-0 rounded-3xl border border-line bg-canvas object-contain p-3"
src={app.icon}
alt=""
width="96"
height="96"
/>
) : (
<div class="grid h-24 w-24 shrink-0 place-items-center rounded-3xl bg-brand text-2xl font-black text-white">
{fallback}
</div>
)
}
<div class="min-w-0 flex-1">
<h1 class="text-3xl font-extrabold tracking-tight">{app.name}</h1>
<p class="mt-1 text-sm text-muted">
{app.developer || 'Community package'}
{app.proprietary ? <span class="ml-2 chip">Proprietary</span> : null}
</p>
<p class="mt-2 max-w-2xl text-muted">{app.summary}</p>
</div>
<div class="flex shrink-0 flex-col items-start gap-2 sm:items-end">
<div class="relative inline-flex" data-install>
<button
type="button"
class="inline-flex h-12 items-center gap-2 rounded-l-xl bg-brand pr-5 pl-6 text-base font-semibold text-white shadow-sm transition hover:bg-brand-dark"
data-install-copy={app.installCmd}
title={app.installCmd}
>
<svg viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4" aria-hidden="true">
<path d="M5.75 1A1.75 1.75 0 0 0 4 2.75v.5h-.25A1.75 1.75 0 0 0 2 5v8.25c0 .966.784 1.75 1.75 1.75h6.5A1.75 1.75 0 0 0 12 13.25v-.5h.25A1.75 1.75 0 0 0 14 11V5a.75.75 0 0 0-.22-.53l-3.25-3.25A.75.75 0 0 0 10 1H5.75ZM10 2.56 12.44 5H10V2.56ZM3.5 5c0-.138.112-.25.25-.25H4V11c0 .966.784 1.75 1.75 1.75H10.5v.5a.25.25 0 0 1-.25.25h-6.5a.25.25 0 0 1-.25-.25V5Z"></path>
</svg>
<span data-install-label>Install</span>
</button>
<button
type="button"
class="inline-flex h-12 items-center rounded-r-xl border-l border-white/25 bg-brand px-3 text-white transition hover:bg-brand-dark"
data-install-toggle
aria-haspopup="true"
aria-expanded="false"
aria-label="More install options"
>
<svg viewBox="0 0 16 16" fill="currentColor" class="h-4 w-4" aria-hidden="true">
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"></path>
</svg>
</button>
<div
class="absolute top-[calc(100%+0.5rem)] right-0 z-30 w-72 overflow-hidden rounded-xl border border-line bg-surface text-left shadow-lg"
data-install-menu
hidden
>
<a href={app.refUrl} download class="flex items-start gap-3 px-4 py-3 hover:bg-canvas">
<svg viewBox="0 0 16 16" fill="currentColor" class="mt-0.5 h-4 w-4 text-muted" aria-hidden="true">
<path d="M8 1a.75.75 0 0 1 .75.75v6.69l1.97-1.97a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.53a.75.75 0 0 1 1.06-1.06l1.97 1.97V1.75A.75.75 0 0 1 8 1Z"></path>
<path d="M2.75 10a.75.75 0 0 1 .75.75v1.75c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V10.75a.75.75 0 0 1 1.5 0v1.75A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.5V10.75A.75.75 0 0 1 2.75 10Z"></path>
</svg>
<span>
<span class="block text-sm font-semibold text-ink">Download .flatpakref</span>
<span class="block text-xs text-muted">Open with your software installer</span>
</span>
</a>
</div>
</div>
<a href="/setup/" class="text-xs text-muted hover:text-brand">First time? Set up {repo.title} &rarr;</a>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span class="chip">{app.category}</span>
{app.license ? <span class="chip">{app.license.label}</span> : null}
{latest ? <span class="chip">v{latest.version}</span> : null}
</div>
</section>
{/* left column: screenshot carousel (if any) + About, stacked under the image;
right column: the Details + Permissions panel. Keeping About on the left
fills the space beside a tall info panel instead of leaving a big gap. */}
<section class="mx-auto grid max-w-5xl items-start gap-8 px-5 py-10 lg:grid-cols-[1.7fr_1fr]">
<div class="flex min-w-0 flex-col gap-8">
{hasShots ? <Screenshots screenshots={app.screenshots} /> : null}
<div>
<h2 class="text-lg font-bold">About</h2>
<div class="mt-3 flex flex-col gap-3 text-[15px] leading-relaxed text-ink/90">
{
app.description?.length > 0 ? (
app.description.map((p) => <p>{p}</p>)
) : (
<p class="text-muted">{app.summary}</p>
)
}
</div>
</div>
</div>
<InfoPanel rows={detailRows} permissions={app.permissions} />
</section>
</main>
<footer class="border-t border-line">
<div class="mx-auto max-w-5xl px-5 py-7 text-sm text-muted">
<a class="text-brand" href="/">&larr; Back to {repo.title}</a>
</div>
</footer>
</Base>
+30
View File
@@ -0,0 +1,30 @@
---
import Base from '../layouts/Base.astro';
import Topbar from '../components/Topbar.astro';
import Hero from '../components/Hero.astro';
import AppCard from '../components/AppCard.astro';
import { loadCatalog } from '../lib/data.mjs';
const { repo, apps } = loadCatalog();
---
<Base title={repo.title}>
<Topbar repo={repo} />
<main>
<Hero count={apps.length} />
<section id="apps" class="mx-auto max-w-6xl px-5 pb-12">
<div class="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4 border-t border-line pt-8">
{apps.map((app) => <AppCard app={app} />)}
</div>
<div
class="rounded-2xl border border-dashed border-line p-12 text-center text-muted"
data-empty
hidden
>
<h2 class="font-bold text-ink">No matching apps</h2>
<p>Try a different search.</p>
</div>
</section>
</main>
</Base>
+96
View File
@@ -0,0 +1,96 @@
---
import Base from '../layouts/Base.astro';
import Topbar from '../components/Topbar.astro';
import { loadCatalog } from '../lib/data.mjs';
const { repo } = loadCatalog();
const scopes = [
{ id: 'user', label: 'Per-user', flag: '--user ' },
{ id: 'system', label: 'System-wide', flag: '' },
];
const buildSteps = (flag) => [
{
title: `Add the ${repo.title} remote`,
body: `Registers the signed ${repo.title} repository. You only do this once.`,
cmd: `flatpak ${flag}remote-add --if-not-exists ${repo.remoteName} ${repo.repoFileUrl}`,
},
{
title: 'Add Flathub for runtimes',
body: `${repo.title} apps build on the freedesktop runtime hosted on Flathub. Skip if you already have it.`,
cmd: `flatpak ${flag}remote-add --if-not-exists ${repo.runtimeRemoteName} ${repo.runtimeRepoUrl}`,
},
{
title: 'Install an app',
body: 'Or just press Install on any app page — it opens your software installer.',
cmd: `flatpak ${flag}install ${repo.remoteName} <app-id>`,
},
];
---
<Base title={`Set up — ${repo.title}`}>
<Topbar repo={repo} />
<main class="mx-auto max-w-3xl px-5 py-12">
<a href="/" class="text-sm text-muted hover:text-ink">&larr; All apps</a>
<h1 class="mt-4 text-3xl font-extrabold tracking-tight">Set up {repo.title}</h1>
<p class="mt-3 max-w-2xl text-muted">
{repo.title} is a signed Flatpak remote. Add it once, then install any app from the catalog —
with the CLI or the Install button. The whole remote is signed with a single GPG key.
</p>
<div class="mt-6 rounded-xl border border-line bg-canvas p-4 text-sm text-muted">
<p class="font-semibold text-ink">Per-user or system-wide?</p>
<ul class="mt-2 flex flex-col gap-1.5">
<li><span class="font-semibold text-ink">Per-user</span> — installs just for you, no admin needed.</li>
<li><span class="font-semibold text-ink">System-wide</span> — installs for everyone, asks for admin.</li>
</ul>
<p class="mt-2">Keep both commands on the same tab — don't mix scopes. The commands update to match.</p>
</div>
<div data-tabs class="mt-8">
<div role="tablist" aria-label="Install scope" class="inline-flex gap-1 rounded-lg border border-line bg-surface p-1">
{
scopes.map((scope, i) => (
<button
type="button"
role="tab"
data-tab={scope.id}
aria-selected={i === 0 ? 'true' : 'false'}
class:list={['seg', { 'is-active': i === 0 }]}
>
{scope.label}
</button>
))
}
</div>
{
scopes.map((scope, i) => (
<ol data-panel={scope.id} hidden={i !== 0} class="mt-6 flex flex-col gap-6">
{buildSteps(scope.flag).map((step, j) => (
<li class="rounded-2xl border border-line bg-surface p-5">
<div class="flex items-center gap-3">
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-brand/10 text-sm font-bold text-brand">
{j + 1}
</span>
<h2 class="text-base font-bold">{step.title}</h2>
</div>
<p class="mt-2 text-sm text-muted">{step.body}</p>
<div class="mt-3 flex items-center gap-2">
<code class="cmd flex-1">{step.cmd}</code>
<button class="btn-ghost shrink-0" type="button" data-copy={step.cmd}>Copy</button>
</div>
</li>
))}
</ol>
))
}
</div>
<p class="mt-8 text-sm text-muted">
Verifying the key and browsing apps? Head back to the
<a class="text-brand hover:underline" href="/">catalog</a>.
</p>
</main>
</Base>
+77
View File
@@ -0,0 +1,77 @@
@import "tailwindcss";
/* FlatPark theme tokens — Flathub-style light palette with GNOME blue. */
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--color-brand: #3584e4;
--color-brand-dark: #1c71d8;
--color-ink: #1d1d1f;
--color-muted: #5e5c64;
--color-line: #e7e7ea;
--color-surface: #ffffff;
--color-canvas: #fafafb;
}
html {
font-family: var(--font-sans);
}
body {
background: var(--color-canvas);
color: var(--color-ink);
-webkit-font-smoothing: antialiased;
}
/* Small repeated component classes built from utilities. */
.chip {
@apply inline-flex min-h-6 items-center rounded-full bg-canvas px-2.5 text-xs font-semibold text-muted;
}
.chip-mode {
@apply bg-brand/10 text-brand;
}
.cmd {
@apply block whitespace-pre-wrap break-words rounded-lg border border-line bg-[#1d1f24]
px-3 py-2.5 font-mono text-sm text-[#e9eaed];
}
.btn-ghost {
@apply inline-flex h-9 items-center rounded-lg border border-line bg-surface px-3
text-sm font-semibold hover:border-brand;
}
.btn-primary {
@apply inline-flex h-9 items-center rounded-lg bg-brand px-3 text-sm font-semibold
text-white hover:bg-brand-dark;
}
.seg {
@apply h-9 rounded-md px-3 font-semibold text-muted;
}
.seg.is-active {
@apply bg-canvas text-ink;
}
/* Hero folds away on first scroll to reveal the catalog. The 1fr -> 0fr grid
trick animates to the exact content height with no max-height guesswork. */
.hero-collapse {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.5s ease, opacity 0.4s ease;
}
.hero-collapse.is-collapsed {
grid-template-rows: 0fr;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.hero-collapse {
transition: none;
}
}
+95
View File
@@ -0,0 +1,95 @@
// Batch-check the external URLs the site hotlinks (screenshots, homepage,
// help, license, source). Reads enriched apps/<id>.json from the data dir.
// Human summary by default, --json for machine output. Exits 1 if any link
// is broken so CI / a remediation job can act on it.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const dataDir = process.env.FLATPARK_DATA_DIR || 'public';
const appsDir = join(dataDir, 'apps');
const asJson = process.argv.includes('--json');
const CONCURRENCY = Number(process.env.LINK_CHECK_CONCURRENCY || 8);
const TIMEOUT = Number(process.env.LINK_CHECK_TIMEOUT_MS || 10000);
function collect() {
const urls = [];
if (!existsSync(appsDir)) return urls;
for (const f of readdirSync(appsDir).filter((n) => n.endsWith('.json'))) {
const app = JSON.parse(readFileSync(join(appsDir, f), 'utf8'));
const add = (type, url) => {
if (typeof url === 'string' && /^https?:\/\//.test(url)) urls.push({ app: app.id, type, url });
};
for (const s of app.screenshots || []) add('screenshot', s.remoteUrl || s.url);
add('website', app.website);
for (const [k, v] of Object.entries(app.urls || {})) add(`url:${k}`, v);
if (app.license?.url) add('license', app.license.url);
add('source', app.sourceUrl);
}
const seen = new Set();
return urls.filter((u) => {
const k = `${u.app}|${u.type}|${u.url}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
async function request(method, url) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
try {
return await fetch(url, { method, redirect: 'follow', signal: ctrl.signal });
} finally {
clearTimeout(timer);
}
}
async function check(entry) {
try {
// Prefer HEAD; many servers reject/ignore it (or block hotlinking), so on
// any HEAD failure or 403/405/501 fall back to GET before declaring it dead.
let res = await request('HEAD', entry.url).catch(() => null);
if (!res || [403, 405, 501].includes(res.status)) {
res = await request('GET', entry.url);
}
const out = { ...entry, status: res.status, ok: res.ok };
try { await res.body?.cancel(); } catch {}
return out;
} catch (e) {
return { ...entry, status: 0, ok: false, error: e.name === 'AbortError' ? 'timeout' : (e.cause?.code || e.message) };
}
}
async function pool(items, n, fn) {
const out = [];
let i = 0;
await Promise.all(
Array.from({ length: Math.min(n, items.length) || 1 }, async () => {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx]);
}
}),
);
return out;
}
const entries = collect();
const results = await pool(entries, CONCURRENCY, check);
const broken = results.filter((r) => !r.ok);
if (asJson) {
console.log(JSON.stringify({ checked: results.length, broken: broken.length, results }, null, 2));
} else {
console.log(`[check-links] checked ${results.length} link(s)`);
if (broken.length === 0) {
console.log('[check-links] all links OK');
} else {
console.log(`[check-links] ${broken.length} broken:`);
for (const b of broken) {
console.log(` x ${b.app} [${b.type}] ${b.status || b.error} ${b.url}`);
}
}
}
process.exit(broken.length > 0 ? 1 : 0);
+216
View File
@@ -0,0 +1,216 @@
// Enrich each base apps/<id>.json (from gen-apps-json.sh) with rich metadata
// parsed from the developer repo's Flatpak manifest, AppStream metainfo, and
// flatpark.yml. Best-effort: a missing or malformed source is skipped, never
// fatal, so the build always proceeds.
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import YAML from 'yaml';
import { XMLParser } from 'fast-xml-parser';
const dataDir = process.env.FLATPARK_DATA_DIR || 'public';
const appsDir = join(dataDir, 'apps');
const toArray = (x) => (x == null ? [] : Array.isArray(x) ? x : [x]);
const textOf = (x) => (x == null ? '' : typeof x === 'object' ? String(x['#text'] ?? '') : String(x)).trim();
const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true });
function parseManifest(path) {
const raw = readFileSync(path, 'utf8');
try {
return JSON.parse(raw);
} catch {
return YAML.parse(raw);
}
}
function parseLicense(raw) {
if (!raw) return null;
const m = /^LicenseRef-([^=]+)(?:=(.+))?$/.exec(raw);
if (m) return { label: m[1] === 'proprietary' ? 'Proprietary' : m[1], url: m[2] || null };
return { label: raw, url: null };
}
// Map a single Flatpak finish-arg to a human label + risk level + group.
const NON_PERMISSION_FLAGS = new Set([
'require-version', 'env', 'extra-languages', 'cwd', 'metadata', 'sdk', 'command', 'runtime', 'version',
]);
function describePermission(arg) {
const eq = arg.indexOf('=');
const flag = (eq === -1 ? arg : arg.slice(0, eq)).replace(/^--/, '');
const value = eq === -1 ? '' : arg.slice(eq + 1);
// These are build/metadata directives, not sandbox holes — don't list them.
if (NON_PERMISSION_FLAGS.has(flag)) return null;
const base = (label, level, group, detail) => ({ flag, value, label, level, group, detail: detail || '' });
switch (flag) {
case 'share':
if (value === 'network') return base('Network access', 'caution', 'Network', 'Can reach the internet and local network');
if (value === 'ipc') return base('Inter-process communication', 'info', 'System', 'Shares the IPC namespace with the host');
return base(`Share: ${value}`, 'info', 'System');
case 'socket': {
const map = {
x11: ['X11 windowing system', 'caution', 'Display', 'Legacy display protocol; can observe input to other X11 windows'],
'fallback-x11': ['X11 (fallback)', 'info', 'Display'],
wayland: ['Wayland display', 'safe', 'Display'],
pulseaudio: ['Audio (PulseAudio)', 'info', 'Devices'],
pipewire: ['Audio/video (PipeWire)', 'info', 'Devices'],
'session-bus': ['Full session bus access', 'warning', 'Services'],
'system-bus': ['Full system bus access', 'warning', 'Services'],
'ssh-auth': ['SSH agent', 'info', 'System'],
cups: ['Printing (CUPS)', 'info', 'System'],
'gpg-agent': ['GPG agent', 'info', 'System'],
};
const m = map[value];
return m ? base(m[0], m[1], m[2], m[3]) : base(`Socket: ${value}`, 'info', 'System');
}
case 'device': {
const map = {
dri: ['GPU acceleration', 'safe', 'Devices'],
all: ['All devices (incl. cameras, USB)', 'warning', 'Devices'],
kvm: ['Virtualization (KVM)', 'caution', 'Devices'],
shm: ['Shared memory', 'info', 'Devices'],
input: ['Input devices', 'caution', 'Devices'],
usb: ['USB devices', 'caution', 'Devices'],
};
const m = map[value];
return m ? base(m[0], m[1], m[2]) : base(`Device: ${value}`, 'info', 'Devices');
}
case 'filesystem': {
const v = value.replace(/:(ro|rw|create)$/, '');
const mode = value.endsWith(':ro') ? ' (read-only)' : '';
const map = {
host: ['All system files', 'warning', 'Filesystem'],
'host-os': ['Host OS files', 'warning', 'Filesystem'],
'host-etc': ['Host /etc', 'warning', 'Filesystem'],
home: ['Home folder', 'caution', 'Filesystem'],
};
const m = map[v];
if (m) return base(m[0] + mode, m[1], m[2]);
return base(`Files: ${value}`, 'caution', 'Filesystem');
}
case 'talk-name':
return base(`Talk to ${value}`, 'info', 'Services');
case 'system-talk-name':
return base(`System service: ${value}`, 'caution', 'Services');
case 'own-name':
return base(`Owns service ${value}`, 'info', 'Services');
case 'persist':
return base(`Persistent storage: ${value}`, 'info', 'Filesystem');
default:
return base(value ? `${flag}: ${value}` : flag, 'info', 'System');
}
}
function enrichOne(file) {
const path = join(appsDir, file);
const base = JSON.parse(readFileSync(path, 'utf8'));
const srcDir = base._srcDir;
const out = { ...base };
delete out._srcDir;
delete out._manifest;
// defaults so the page can render unconditionally
out.developer = '';
out.license = null;
out.description = [];
out.screenshots = [];
out.releases = [];
out.urls = {};
out.categories = out.category ? [out.category] : [];
out.contentRating = [];
out.runtime = '';
out.runtimeVersion = '';
out.command = '';
out.proprietary = false;
out.maintainer = null;
out.permissions = [];
// 1. Flatpak manifest -> permissions, runtime, proprietary tag
try {
if (base._manifest && existsSync(base._manifest)) {
const m = parseManifest(base._manifest);
out.runtime = m.runtime || '';
out.runtimeVersion = m['runtime-version'] ? String(m['runtime-version']) : '';
out.command = m.command || '';
out.permissions = toArray(m['finish-args']).map(describePermission).filter(Boolean);
if (toArray(m.tags).includes('proprietary')) out.proprietary = true;
}
} catch (e) {
console.warn(`[enrich] ${base.id}: manifest parse failed: ${e.message}`);
}
// 2. AppStream metainfo -> description, screenshots, releases, developer, license, urls
try {
const candidates = [
join(srcDir || '', `${base.id}.metainfo.xml`),
join(srcDir || '', `${base.id}.appdata.xml`),
];
const metainfoPath = candidates.find((p) => p && existsSync(p));
if (metainfoPath) {
const comp = xml.parse(readFileSync(metainfoPath, 'utf8')).component || {};
out.description = toArray(comp.description?.p).map(textOf).filter(Boolean);
out.screenshots = toArray(comp.screenshots?.screenshot).map((s) => {
const imgs = toArray(s.image);
const img = imgs.find((i) => (typeof i === 'object' ? i['@_type'] : 'source') === 'source') || imgs[0];
return { url: textOf(img), caption: textOf(s.caption) };
}).filter((s) => s.url);
out.releases = toArray(comp.releases?.release).map((r) => ({
version: String(r['@_version'] ?? ''),
date: String(r['@_date'] ?? ''),
})).filter((r) => r.version);
out.developer = textOf(comp.developer?.name) || textOf(comp.developer_name) || '';
const lic = parseLicense(textOf(comp.project_license));
if (lic) out.license = lic;
for (const u of toArray(comp.url)) {
const type = typeof u === 'object' ? u['@_type'] : 'homepage';
if (type) out.urls[type] = textOf(u);
}
const cats = toArray(comp.categories?.category).map(textOf).filter(Boolean);
if (cats.length) out.categories = cats;
out.contentRating = toArray(comp.content_rating?.content_attribute).map((a) => ({
id: typeof a === 'object' ? a['@_id'] : '',
value: textOf(a),
})).filter((a) => a.id);
}
} catch (e) {
console.warn(`[enrich] ${base.id}: metainfo parse failed: ${e.message}`);
}
// 3. flatpark.yml -> maintainer, website, policy
try {
const fyPath = join(srcDir || '', 'flatpark.yml');
if (existsSync(fyPath)) {
const fy = YAML.parse(readFileSync(fyPath, 'utf8')) || {};
if (fy.maintainer) out.maintainer = fy.maintainer;
if (fy.website) out.website = fy.website;
if (fy.policy?.proprietary) out.proprietary = true;
if (fy.build?.mode) out.buildMode = fy.build.mode;
}
} catch (e) {
console.warn(`[enrich] ${base.id}: flatpark.yml parse failed: ${e.message}`);
}
if (!out.website) out.website = out.urls.homepage || '';
if (out.license?.label === 'Proprietary') out.proprietary = true;
writeFileSync(path, JSON.stringify(out, null, 2) + '\n');
return out.id;
}
if (!existsSync(appsDir)) {
console.warn(`[enrich] no apps dir at ${appsDir}; nothing to enrich`);
process.exit(0);
}
const files = readdirSync(appsDir).filter((f) => f.endsWith('.json'));
let n = 0;
for (const f of files) {
try {
enrichOne(f);
n += 1;
} catch (e) {
console.warn(`[enrich] ${f}: ${e.message}`);
}
}
console.log(`[enrich] enriched ${n}/${files.length} app file(s) in ${appsDir}`);
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"types": ["node"]
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "public"]
}
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
echo "FlatPark synthetic build fixture"
+9
View File
@@ -0,0 +1,9 @@
id: io.flatpark.BuildOne
name: Build One
summary: Synthetic build fixture
build:
manifest: io.flatpark.BuildOne.yml
branch: stable
mode: manual
catalog:
category: Utilities
@@ -0,0 +1,6 @@
[Desktop Entry]
Name=Build One
Exec=build-one
Type=Application
Icon=io.flatpark.BuildOne
Categories=Utility;
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.flatpark.BuildOne</id>
<name>Build One</name>
<summary>Synthetic build fixture</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<launchable type="desktop-id">io.flatpark.BuildOne.desktop</launchable>
<description>
<p>A synthetic application used by FlatPark's build and publish tests.</p>
</description>
</component>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#3b82f6"/><text x="32" y="42" font-size="32" text-anchor="middle" fill="#fff" font-family="sans-serif">F</text></svg>

After

Width:  |  Height:  |  Size: 244 B

@@ -0,0 +1,26 @@
id: io.flatpark.BuildOne
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: build-one
build-options:
no-debuginfo: true
finish-args:
- --share=network
modules:
- name: build-one
buildsystem: simple
build-commands:
- install -Dm755 build-one.sh /app/bin/build-one
- install -Dm644 io.flatpark.BuildOne.desktop /app/share/applications/io.flatpark.BuildOne.desktop
- install -Dm644 io.flatpark.BuildOne.metainfo.xml /app/share/metainfo/io.flatpark.BuildOne.metainfo.xml
- install -Dm644 io.flatpark.BuildOne.svg /app/share/icons/hicolor/scalable/apps/io.flatpark.BuildOne.svg
sources:
- type: file
path: build-one.sh
- type: file
path: io.flatpark.BuildOne.desktop
- type: file
path: io.flatpark.BuildOne.metainfo.xml
- type: file
path: io.flatpark.BuildOne.svg
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Dependency-free assertion helpers. Each asserts or exits non-zero.
assert_eq() { [ "$1" = "$2" ] || { echo "FAIL assert_eq: got [$1] want [$2]"; exit 1; }; }
assert_file() { [ -f "$1" ] || { echo "FAIL assert_file: missing $1"; exit 1; }; }
assert_contains() { grep -qF -- "$2" "$1" || { echo "FAIL assert_contains: $1 lacks [$2]"; exit 1; }; }
assert_ok() { "$@" || { echo "FAIL assert_ok: $*"; exit 1; }; }
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Run every tests/test_*.sh; report pass/fail; exit non-zero on any failure.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail=0
for t in "$ROOT"/tests/test_*.sh; do
name="$(basename "$t")"
if bash "$t"; then echo "ok $name"; else echo "FAIL $name"; fail=1; fi
done
exit "$fail"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v flatpak-builder >/dev/null || { echo "test_build_app: SKIP (no flatpak-builder)"; exit 0; }
[ -x "$ROOT/scripts/build-app.sh" ] || { echo "FAIL: missing build script"; exit 1; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
# Build the self-contained synthetic fixture: registry/<id>/ holds the
# descriptor + manifest + assets (tests/fixtures is the registry root).
env_common=(OUT_DIR="$tmp/out" GNUPGHOME_DIR="$tmp/gnupg" REPO_DIR="$tmp/repo" REGISTRY_DIR="$ROOT/tests/fixtures")
env "${env_common[@]}" "$ROOT/scripts/gen-signing-key.sh" >/dev/null
if ! env "${env_common[@]}" "$ROOT/scripts/build-app.sh" io.flatpark.BuildOne; then
echo "test_build_app: SKIP (build failed, likely no runtime/network)"; exit 0
fi
assert_ok ostree --repo="$tmp/repo" refs
ostree --repo="$tmp/repo" refs | grep -q "app/io.flatpark.BuildOne/.*/stable" \
|| { echo "FAIL: app ref not in repo"; exit 1; }
echo "test_build_app: PASS"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v node >/dev/null 2>&1 || { echo "test_check_links: SKIP (no node)"; exit 0; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
data="$tmp/data"; mkdir -p "$data/apps"
# App with no external URLs -> nothing to check.
cat > "$data/apps/io.flatpark.NoLinks.json" <<'EOF'
{ "id": "io.flatpark.NoLinks", "name": "No Links", "screenshots": [], "website": "", "urls": {}, "sourceUrl": "" }
EOF
# App with one dead URL (refused localhost port) -> exercises the broken path
# hermetically, no external network.
cat > "$data/apps/io.flatpark.BadLink.json" <<'EOF'
{ "id": "io.flatpark.BadLink", "name": "Bad Link", "screenshots": [{ "url": "http://127.0.0.1:9/missing.png" }], "website": "", "urls": {}, "sourceUrl": "" }
EOF
# Broken link present -> exit 1, broken count 1.
set +e
out="$(FLATPARK_DATA_DIR="$data" LINK_CHECK_TIMEOUT_MS=2000 node "$ROOT/site/tools/check-links.mjs" --json)"
rc=$?
set -e
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 with a dead link, got $rc"; exit 1; }
printf '%s' "$out" | node -e 'const d=JSON.parse(require("fs").readFileSync(0,"utf8")); if(d.checked!==1||d.broken!==1){console.error("checked="+d.checked+" broken="+d.broken);process.exit(1)}'
# All-clean data dir -> exit 0.
rm "$data/apps/io.flatpark.BadLink.json"
assert_ok env FLATPARK_DATA_DIR="$data" node "$ROOT/site/tools/check-links.mjs"
echo "test_check_links: PASS"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
. "$ROOT/scripts/lib/common.sh"
load_config "$ROOT"
assert_eq "$REPO_TITLE" "FlatPark"
assert_eq "$REPO_URL" "https://dl.flatpark.org/"
assert_eq "$REPO_FILE_URL" "https://dl.flatpark.org/flatpark.flatpakrepo"
assert_eq "$REMOTE_NAME" "flatpark"
assert_eq "$RUNTIME_REMOTE_NAME" "flathub"
assert_eq "$REGISTRY_DIR" "$ROOT/registry"
# load_app against a synthetic descriptor registry — no real app referenced.
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/io.flatpark.TestOne"
cat > "$tmp/io.flatpark.TestOne/flatpark.yml" <<'EOF'
id: io.flatpark.TestOne
name: Test One
summary: Synthetic test app
build:
manifest: io.flatpark.TestOne.yml
EOF
REGISTRY_DIR="$tmp" load_config "$ROOT"
load_app "io.flatpark.TestOne"
assert_eq "$APP_ID" "io.flatpark.TestOne"
assert_eq "$APP_REF_URL" "https://dl.flatpark.org/io.flatpark.TestOne.flatpakref"
assert_eq "$REPO_DIR" "$OUT_DIR/repo"
# env override wins
OUT_DIR="/tmp/flatpark-test-out" load_config "$ROOT"
assert_eq "$OUT_DIR" "/tmp/flatpark-test-out"
assert_eq "$REPO_DIR" "/tmp/flatpark-test-out/repo"
echo "test_config: PASS"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v node >/dev/null 2>&1 || { echo "test_enrich: SKIP (no node)"; exit 0; }
[ -d "$ROOT/site/node_modules/yaml" ] && [ -d "$ROOT/site/node_modules/fast-xml-parser" ] \
|| { echo "test_enrich: SKIP (site deps not installed)"; exit 0; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
registry="$tmp/registry"
app="$registry/io.flatpark.TestOne"
mkdir -p "$app"
cat > "$app/io.flatpark.TestOne.svg" <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="red"/></svg>
EOF
cat > "$app/io.flatpark.TestOne.yml" <<'EOF'
id: io.flatpark.TestOne
runtime: org.freedesktop.Platform
runtime-version: "25.08"
sdk: org.freedesktop.Sdk
command: test-one
finish-args:
- --share=network
- --socket=wayland
- --device=dri
EOF
cat > "$app/io.flatpark.TestOne.metainfo.xml" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.flatpark.TestOne</id>
<name>Test One</name>
<summary>First test app</summary>
<project_license>MIT</project_license>
<developer id="io.flatpark"><name>FlatPark Test Dev</name></developer>
<url type="homepage">https://example.org/</url>
<description><p>A test application for FlatPark.</p></description>
<screenshots><screenshot type="default"><caption>Main</caption><image>https://example.org/shot.png</image></screenshot></screenshots>
<releases><release version="1.0" date="2026-01-01" /></releases>
</component>
EOF
# A single flatpark.yml carries both the registry fields (id/name/summary/build)
# and developer metadata (website/maintainer) that enrich.mjs reads.
cat > "$app/flatpark.yml" <<'EOF'
id: io.flatpark.TestOne
name: Test One
summary: First test app
build:
manifest: io.flatpark.TestOne.yml
catalog:
category: Utilities
website: https://example.org/
maintainer:
github: testuser
email: [email protected]
EOF
data="$tmp/data"
REGISTRY_DIR="$registry" DATA_DIR="$data" "$ROOT/scripts/gen-apps-json.sh"
FLATPARK_DATA_DIR="$data" node "$ROOT/site/tools/enrich.mjs"
out="$data/apps/io.flatpark.TestOne.json"
assert_file "$out"
# permissions mapped from finish-args
assert_contains "$out" "\"label\": \"Network access\""
assert_contains "$out" "\"label\": \"Wayland display\""
assert_contains "$out" "\"label\": \"GPU acceleration\""
# metainfo-derived fields
assert_contains "$out" "\"FlatPark Test Dev\""
assert_contains "$out" "A test application for FlatPark."
assert_contains "$out" "\"version\": \"1.0\""
assert_contains "$out" "\"label\": \"MIT\""
assert_contains "$out" "https://example.org/shot.png"
# flatpark.yml-derived maintainer
assert_contains "$out" "\"github\": \"testuser\""
# enrichment must strip the private source-path fields
if grep -q "_manifest\|_srcDir" "$out"; then echo "FAIL: enriched file still has _ fields"; exit 1; fi
assert_ok node -e "JSON.parse(require('fs').readFileSync('$out','utf8'))"
echo "test_enrich: PASS"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
registry="$tmp/registry"
one_dir="$registry/io.flatpark.TestOne"
two_dir="$registry/io.flatpark.TestTwo"
mkdir -p "$one_dir" "$two_dir"
# Icon is auto-detected from <APP_SRC>/<id>.svg (APP_SRC defaults to the app dir).
cat > "$one_dir/io.flatpark.TestOne.svg" <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="red"/></svg>
EOF
cat > "$one_dir/flatpark.yml" <<'EOF'
id: io.flatpark.TestOne
name: Test One
summary: First test app
build:
manifest: test-one.yml
catalog:
category: Utilities
tags:
- Local
- Test
EOF
# TestTwo ships only a PNG icon (no SVG); the resolver falls back to <id>.png.
printf '\x89PNG\r\n\x1a\n' > "$two_dir/io.flatpark.TestTwo.png"
cat > "$two_dir/flatpark.yml" <<'EOF'
id: io.flatpark.TestTwo
name: Test Two
summary: Second test app
build:
manifest: test-two.yml
catalog:
category: Finance
tags:
- Markets
EOF
data="$tmp/data"
REGISTRY_DIR="$registry" DATA_DIR="$data" "$ROOT/scripts/gen-apps-json.sh"
catalog="$data/catalog.json"
one="$data/apps/io.flatpark.TestOne.json"
two="$data/apps/io.flatpark.TestTwo.json"
# Light catalog: card fields for every app, plus repo block.
assert_file "$catalog"
assert_contains "$catalog" "\"id\": \"io.flatpark.TestOne\""
assert_contains "$catalog" "\"id\": \"io.flatpark.TestTwo\""
assert_contains "$catalog" "\"name\": \"Test One\""
assert_contains "$catalog" "\"category\": \"Finance\""
assert_contains "$catalog" "\"remoteCmd\": \"flatpak --user remote-add --if-not-exists flatpark"
# Catalog stays light: no per-app heavy fields leak in.
if grep -q "_manifest" "$catalog"; then echo "FAIL: catalog carries _manifest"; exit 1; fi
# Base per-app files carry install data + source paths for enrichment.
assert_file "$one"
assert_file "$two"
assert_file "$data/icons/io.flatpark.TestOne.svg"
assert_file "$data/icons/io.flatpark.TestTwo.png"
assert_contains "$one" "\"installCmd\": \"flatpak --user install flatpark io.flatpark.TestOne\""
assert_contains "$one" "\"_manifest\": \"$one_dir/test-one.yml\""
assert_contains "$one" "\"_srcDir\": \"$one_dir\""
# Valid JSON.
if command -v node >/dev/null 2>&1; then
assert_ok node -e "['$catalog','$one','$two'].forEach(f=>JSON.parse(require('fs').readFileSync(f,'utf8')))"
fi
echo "test_gen_apps_json: PASS"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
reg="$tmp/registry"; mkdir -p "$reg/io.flatpark.TestOne"
cat > "$reg/io.flatpark.TestOne/flatpark.yml" <<'EOF'
id: io.flatpark.TestOne
name: Test One
summary: Synthetic test app
build:
manifest: io.flatpark.TestOne.yml
EOF
env_common=(OUT_DIR="$tmp/out" GNUPGHOME_DIR="$tmp/gnupg" REPO_URL="file://$tmp/out/repo" REGISTRY_DIR="$reg")
env "${env_common[@]}" "$ROOT/scripts/gen-signing-key.sh" >/dev/null
env "${env_common[@]}" "$ROOT/scripts/gen-discovery.sh" io.flatpark.TestOne
repo="$tmp/out/flatpark.flatpakrepo"
repo_canonical="$tmp/out/repo/flatpark.flatpakrepo"
ref="$tmp/out/io.flatpark.TestOne.flatpakref"
ref_canonical="$tmp/out/repo/io.flatpark.TestOne.flatpakref"
assert_file "$repo"
assert_file "$repo_canonical"
assert_contains "$repo" "Title=FlatPark"
assert_contains "$repo" "Url=file://$tmp/out/repo"
assert_contains "$repo" "GPGKey="
assert_file "$ref"
assert_file "$ref_canonical"
assert_contains "$ref" "Name=io.flatpark.TestOne"
assert_contains "$ref" "RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo"
# GPGKey line must carry a non-empty base64 payload
grep -q "^GPGKey=..*" "$repo" || { echo "FAIL: empty GPGKey"; exit 1; }
echo "test_gen_discovery: PASS"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
fpr="$(OUT_DIR="$tmp/out" GNUPGHOME_DIR="$tmp/gnupg" "$ROOT/scripts/gen-signing-key.sh")"
assert_file "$tmp/out/flatpark.pub.asc"
assert_contains "$tmp/out/flatpark.pub.asc" "BEGIN PGP PUBLIC KEY BLOCK"
[ -n "$fpr" ] || { echo "FAIL: empty fingerprint"; exit 1; }
# idempotent: second run reuses the same key, same fingerprint
fpr2="$(OUT_DIR="$tmp/out" GNUPGHOME_DIR="$tmp/gnupg" "$ROOT/scripts/gen-signing-key.sh")"
assert_eq "$fpr2" "$fpr"
echo "test_gen_signing_key: PASS"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v node >/dev/null 2>&1 || { echo "test_gen_site: SKIP (no node)"; exit 0; }
command -v npm >/dev/null 2>&1 || { echo "test_gen_site: SKIP (no npm)"; exit 0; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
registry="$tmp/registry"
one_dir="$registry/io.flatpark.TestOne"
two_dir="$registry/io.flatpark.TestTwo"
mkdir -p "$one_dir" "$two_dir"
cat > "$one_dir/io.flatpark.TestOne.svg" <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="red"/></svg>
EOF
cat > "$one_dir/io.flatpark.TestOne.yml" <<'EOF'
id: io.flatpark.TestOne
runtime: org.freedesktop.Platform
runtime-version: "25.08"
command: test-one
finish-args:
- --share=network
- --socket=wayland
EOF
cat > "$one_dir/io.flatpark.TestOne.metainfo.xml" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.flatpark.TestOne</id>
<name>Test One</name>
<summary>First test app</summary>
<project_license>MIT</project_license>
<developer id="io.flatpark"><name>FlatPark Test Dev</name></developer>
<description><p>A test application for FlatPark.</p></description>
</component>
EOF
cat > "$one_dir/flatpark.yml" <<'EOF'
id: io.flatpark.TestOne
name: Test One
summary: First test app
build:
manifest: io.flatpark.TestOne.yml
catalog:
category: Utilities
EOF
cat > "$two_dir/flatpark.yml" <<'EOF'
id: io.flatpark.TestTwo
name: Test Two
summary: Second test app
build:
manifest: io.flatpark.TestTwo.yml
catalog:
category: Finance
EOF
data="$tmp/data"
REGISTRY_DIR="$registry" DATA_DIR="$data" "$ROOT/scripts/gen-apps-json.sh"
# Build needs site deps; install if absent. SKIP if that or the build fails (offline).
if [ ! -d "$ROOT/site/node_modules" ]; then
if ! ( cd "$ROOT/site" && npm install --no-audit --no-fund >/dev/null 2>&1 ); then
echo "test_gen_site: SKIP (npm install failed, likely offline)"; exit 0
fi
fi
FLATPARK_DATA_DIR="$data" node "$ROOT/site/tools/enrich.mjs" >/dev/null 2>&1 || true
if ! ( cd "$ROOT/site" && FLATPARK_DATA_DIR="$data" SITE_OUT_DIR="$tmp/site" \
npm run build >/dev/null 2>&1 ); then
echo "test_gen_site: SKIP (astro build failed)"; exit 0
fi
index="$tmp/site/index.html"
detail="$tmp/site/apps/io.flatpark.TestOne/index.html"
setup="$tmp/site/setup/index.html"
assert_file "$index"
assert_contains "$index" "FlatPark"
assert_contains "$index" "Test One"
assert_contains "$index" "Test Two"
assert_contains "$index" "Search apps"
assert_contains "$index" "data-app-card"
assert_contains "$index" "Finance"
assert_contains "$index" "/apps/io.flatpark.TestOne/"
# Detail page: Flathub-style Install button (flatpakref) + setup link, real
# permissions/description. The manual command block now lives on /setup/.
assert_file "$detail"
assert_contains "$detail" "Test One"
assert_contains "$detail" "Permissions"
assert_contains "$detail" "Network access"
assert_contains "$detail" "FlatPark Test Dev"
assert_contains "$detail" "A test application for FlatPark."
assert_contains "$detail" "io.flatpark.TestOne.flatpakref"
assert_contains "$detail" "/setup/"
# Setup page carries the remote-add command.
assert_file "$setup"
assert_contains "$setup" "flatpak --user remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo"
echo "test_gen_site: PASS"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v flatpak-builder >/dev/null || { echo "test_publish_e2e: SKIP (no flatpak-builder)"; exit 0; }
[ -x "$ROOT/scripts/publish.sh" ] || { echo "FAIL: missing publish script"; exit 1; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
# Publish the self-contained synthetic fixture end-to-end. Pass the explicit id
# so only this fixture is built even if more land under tests/fixtures.
env_common=(
OUT_DIR="$tmp/out" GNUPGHOME_DIR="$tmp/gnupg" REPO_DIR="$tmp/out/repo"
REPO_URL="file://$tmp/out/repo" REGISTRY_DIR="$ROOT/tests/fixtures"
DATA_DIR="$tmp/data" FLATPARK_DATA_DIR="$tmp/data"
)
if ! env "${env_common[@]}" "$ROOT/scripts/publish.sh" io.flatpark.BuildOne; then
echo "test_publish_e2e: SKIP (publish failed, likely no runtime/network)"; exit 0
fi
assert_file "$tmp/out/flatpark.flatpakrepo"
assert_file "$tmp/out/io.flatpark.BuildOne.flatpakref"
assert_file "$tmp/out/site/index.html"
assert_file "$tmp/out/repo/summary.sig"
echo "test_publish_e2e: PASS"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v ostree >/dev/null || { echo "test_publish_repo: SKIP (no ostree)"; exit 0; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
env_common=(OUT_DIR="$tmp/out" GNUPGHOME_DIR="$tmp/gnupg")
env "${env_common[@]}" "$ROOT/scripts/gen-signing-key.sh" >/dev/null
repo="$tmp/repo"
ostree --repo="$repo" init --mode=archive-z2
mkdir -p "$tmp/tree/files"; echo hi > "$tmp/tree/files/x"
ostree --repo="$repo" commit --branch=app/test.App/x86_64/stable --subject=t "$tmp/tree" >/dev/null
env "${env_common[@]}" "$ROOT/scripts/publish-repo.sh" "$repo"
assert_file "$repo/summary"
assert_file "$repo/summary.sig"
echo "test_publish_repo: PASS"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
. "$ROOT/scripts/lib/common.sh"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
reg="$tmp/registry"; mkdir -p "$reg/io.flatpark.TestOne"
cat > "$reg/io.flatpark.TestOne/flatpark.yml" <<'EOF'
id: io.flatpark.TestOne
name: Test One
summary: First test app
build:
manifest: io.flatpark.TestOne.yml
branch: stable
mode: extra-data-url
catalog:
category: Finance
tags:
- Trading
- Markets
- Workstation
EOF
REGISTRY_DIR="$reg" load_config "$ROOT"
load_app "io.flatpark.TestOne"
assert_eq "$APP_NAME" "Test One"
assert_eq "$APP_BRANCH" "stable"
assert_eq "$UPDATE_MODE" "extra-data-url"
assert_eq "$MANIFEST" "$reg/io.flatpark.TestOne/io.flatpark.TestOne.yml"
assert_eq "$APP_SRC" "$reg/io.flatpark.TestOne"
assert_eq "$APP_CATEGORY" "Finance"
assert_eq "$APP_TAGS" "Trading, Markets, Workstation"
ids="$(REGISTRY_DIR="$reg" "$ROOT/scripts/scan-registry.sh" --ids)"
assert_eq "$ids" "io.flatpark.TestOne"
scan="$(REGISTRY_DIR="$reg" "$ROOT/scripts/scan-registry.sh" io.flatpark.TestOne)"
case "$scan" in
*"io.flatpark.TestOne"*"extra-data-url"*) ;;
*) echo "FAIL: scan output missing app/update mode: $scan"; exit 1 ;;
esac
# explicit env overrides must win over the descriptor defaults
override_scan="$(REGISTRY_DIR="$reg" APP_SRC="/tmp/flatpark-app-src" MANIFEST="/tmp/flatpark-app.yml" \
"$ROOT/scripts/scan-registry.sh" io.flatpark.TestOne)"
case "$override_scan" in
*"/tmp/flatpark-app.yml") ;;
*) echo "FAIL: scan did not preserve explicit manifest override: $override_scan"; exit 1 ;;
esac
echo "test_registry: PASS"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT/tests/lib/assert.sh"
command -v node >/dev/null 2>&1 || { echo "test_update_pins: SKIP (no node)"; exit 0; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
printf 'OLD-CONTENT' > "$tmp/old.bin"
printf 'NEW-CONTENT-LONGER' > "$tmp/new.bin"
old_sha="$(sha256sum "$tmp/old.bin" | cut -d' ' -f1)"; old_size="$(wc -c < "$tmp/old.bin")"
new_sha="$(sha256sum "$tmp/new.bin" | cut -d' ' -f1)"; new_size="$(wc -c < "$tmp/new.bin")"
mk_manifest() { # <url> <sha> <size>
cat > "$tmp/manifest.yml" <<EOF
modules:
- name: app
sources:
# BEGIN MANAGED EXTRA-DATA
- type: extra-data
filename: app.bin
only-arches:
- x86_64
url: $1
sha256: $2
size: $3
# END MANAGED EXTRA-DATA
EOF
}
mk_metainfo() { # <version>
cat > "$tmp/app.metainfo.xml" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.flatpark.Up</id>
<name>Up</name>
<releases>
<release version="$1" date="2026-01-01" />
</releases>
</component>
EOF
}
RC=0; OUT=""
run() { # <resolver-json> -> sets RC/OUT (called directly, not piped, so globals stick)
set +e
OUT="$(printf '%s' "$1" | node "$ROOT/scripts/update-pins.mjs" "$tmp/manifest.yml" "$tmp/app.metainfo.xml")"
RC=$?
set -e
}
# 1) version unchanged -> exit 10, nothing rewritten (no download even attempted).
mk_manifest "file://$tmp/old.bin" "$old_sha" "$old_size"
mk_metainfo "1.0"
run "$(printf '{"version":"1.0","sources":[{"filename":"app.bin","url":"file://%s/old.bin"}]}' "$tmp")"
assert_eq "$RC" "10"
# 2) version bump -> exit 0, pins refreshed from the new source, release prepended.
run "$(printf '{"version":"2.0","releaseDate":"2026-02-02","sources":[{"filename":"app.bin","url":"file://%s/new.bin"}]}' "$tmp")"
assert_eq "$RC" "0"
assert_eq "$OUT" "2.0"
assert_contains "$tmp/manifest.yml" "url: file://$tmp/new.bin"
assert_contains "$tmp/manifest.yml" "sha256: $new_sha"
assert_contains "$tmp/manifest.yml" "size: $new_size"
assert_contains "$tmp/app.metainfo.xml" "<release version=\"2.0\" date=\"2026-02-02\" />"
# the new release must be first (newest-first), old one retained
first_ver="$(grep -oE 'release version="[^"]*"' "$tmp/app.metainfo.xml" | head -1)"
assert_eq "$first_ver" 'release version="2.0"'
assert_contains "$tmp/app.metainfo.xml" "<release version=\"1.0\""
# 3) re-running the same resolver is now a no-op (anchor moved to 2.0).
run "$(printf '{"version":"2.0","releaseDate":"2026-02-02","sources":[{"filename":"app.bin","url":"file://%s/new.bin"}]}' "$tmp")"
assert_eq "$RC" "10"
echo "test_update_pins: PASS"