mirror of
https://github.com/Heretek-Games/flatpark.git
synced 2026-10-11 20:59:53 +00:00
init
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}"
|
||||
@@ -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).
|
||||
Executable
+25
@@ -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"
|
||||
Executable
+17
@@ -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
|
||||
Executable
+28
@@ -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 )
|
||||
Executable
+40
@@ -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[@]}"
|
||||
Executable
+27
@@ -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"
|
||||
Executable
+136
@@ -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"
|
||||
Executable
+35
@@ -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"
|
||||
Executable
+33
@@ -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"
|
||||
Executable
+34
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
Executable
+48
@@ -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"
|
||||
Executable
+13
@@ -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"
|
||||
Executable
+46
@@ -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
|
||||
@@ -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');
|
||||
Executable
+29
@@ -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
|
||||
Executable
+51
@@ -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"
|
||||
Executable
+134
@@ -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`);
|
||||
@@ -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()],
|
||||
},
|
||||
});
|
||||
Generated
+6284
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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">← 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} →</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="/">← Back to {repo.title}</a>
|
||||
</div>
|
||||
</footer>
|
||||
</Base>
|
||||
@@ -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>
|
||||
@@ -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">← 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "public"]
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
echo "FlatPark synthetic build fixture"
|
||||
@@ -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
|
||||
@@ -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; }; }
|
||||
Executable
+10
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user