diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 00000000..1c712b46 --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,72 @@ +name: flatpak + +on: + push: + branches: [main] + paths: + - 'desktop/flatpak/**' + - 'tests/test_flatpak.py' + - '.github/workflows/flatpak.yml' + pull_request: + paths: + - 'desktop/flatpak/**' + - 'tests/test_flatpak.py' + - '.github/workflows/flatpak.yml' + +jobs: + validate: + name: validate packaging + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: generate icons + run: python desktop/flatpak/scripts/generate-icons.py + + - name: validate manifest + run: python desktop/flatpak/scripts/validate-manifest.py --strict + + - name: pytest flatpak contract + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + python -m pytest tests/test_flatpak.py -q + + build: + name: flatpak-builder smoke + runs-on: ubuntu-latest + needs: validate + steps: + - uses: actions/checkout@v4 + + - name: install flatpak + run: | + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder + + - name: add flathub + run: sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + + - name: install runtime 23.08 + run: | + sudo flatpak install -y flathub \ + org.freedesktop.Platform//23.08 \ + org.freedesktop.Sdk//23.08 + + - name: generate icons + run: python desktop/flatpak/scripts/generate-icons.py + + - name: build + run: | + cd desktop/flatpak + flatpak-builder --repo=repo --force-clean build-dir com.vouchdev.vouch.yaml + + - name: upload bundle artifact + uses: actions/upload-artifact@v7 + with: + name: flatpak-repo + path: desktop/flatpak/repo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8023df2d..5345268b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,11 @@ All notable changes to vouch are documented here. Format follows as reviewer; a PR opens only when the repo's own test gate is green and the reviewer signs off. A sibling tool — it never writes to the KB or the review gate. Paired with the `auto-pr` skill. +- flatpak desktop package (#211): `desktop/flatpak/com.vouchdev.vouch.yaml` + manifest on `org.freedesktop.Platform//23.08` with `--filesystem=home` and + `--share=network`, shipping `vouch review-ui` via a `vouch-review-ui` launcher. + Includes AppStream metainfo, hicolor icons, Flathub submission template, + validators (`tests/test_flatpak.py`), and a `flatpak` CI workflow. - typed page kinds (#234): a KB can declare extra page kinds in `.vouch/config.yaml` under `page_kinds`, each with `required_fields`, a JSON-Schema-subset `frontmatter_schema`, `required_citations`, and one level diff --git a/Makefile b/Makefile index 03dd2596..a35643e6 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ help: @echo " make type mypy" @echo " make check lint + type + test" @echo " make build build sdist + wheel" + @echo " make flatpak validate flatpak packaging (icons + manifest)" @echo " make clean remove caches, build artifacts, *.egg-info" @echo " make examples-screenshots re-render docs/img/examples/*.svg" @@ -46,6 +47,11 @@ build: $(PY) -m pip install --upgrade build $(PY) -m build +flatpak: + $(PY) desktop/flatpak/scripts/generate-icons.py + $(PY) desktop/flatpak/scripts/validate-manifest.py --strict + $(PY) -m pytest tests/test_flatpak.py -q + clean: rm -rf build dist *.egg-info src/*.egg-info \ .pytest_cache .ruff_cache .mypy_cache \ diff --git a/desktop/README.md b/desktop/README.md index 08c49cf2..ee44a6c4 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -133,6 +133,18 @@ To ship a zero-Python install, freeze vouch with PyInstaller into See [`docs/architecture.md`](./docs/architecture.md) for the full design, including the method-by-method coverage table. +## Linux packaging + +Sandboxed Linux installs for the **review console** live under [`flatpak/`](flatpak/): + +| Path | Format | Status | +|---|---|---| +| [`flatpak/`](flatpak/) | Flatpak (`com.vouchdev.vouch`) | #211 — `org.freedesktop.Platform//23.08` | + +Snap packaging is tracked separately. Each format keeps its own manifest language +and runtime base. See [`docs/desktop-flatpak.md`](../docs/desktop-flatpak.md) and +[`flatpak/README.md`](flatpak/README.md) for build and Flathub submission notes. + ## Status v0.1.0 covers the entire `kb.*` surface plus dual-solve. The CLI-only diff --git a/desktop/flatpak/Makefile b/desktop/flatpak/Makefile new file mode 100644 index 00000000..cb03a89e --- /dev/null +++ b/desktop/flatpak/Makefile @@ -0,0 +1,39 @@ +.PHONY: help icons validate requirements build install run clean + +ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +PY ?= python3 +MANIFEST := $(ROOT)/com.vouchdev.vouch.yaml +BUILD_DIR := $(ROOT)/build-dir +REPO_DIR := $(ROOT)/repo + +help: + @echo "vouch flatpak targets (#211)" + @echo "" + @echo " make icons regenerate hicolor PNGs from SVG" + @echo " make validate run packaging validators (strict)" + @echo " make requirements sync requirements-flatpak.txt from pyproject.toml" + @echo " make build flatpak-builder (no install)" + @echo " make install build + --user --install" + @echo " make run flatpak run com.vouchdev.vouch" + @echo " make clean remove build-dir/ and repo/" + +icons: + $(PY) $(ROOT)/scripts/generate-icons.py + +validate: icons + $(PY) $(ROOT)/scripts/validate-manifest.py --strict + +requirements: + $(PY) $(ROOT)/scripts/generate-requirements.py + +build: validate + flatpak-builder --force-clean --repo=$(REPO_DIR) $(BUILD_DIR) $(MANIFEST) + +install: validate + flatpak-builder --user --install --force-clean --repo=$(REPO_DIR) $(BUILD_DIR) $(MANIFEST) + +run: + flatpak run com.vouchdev.vouch + +clean: + rm -rf $(BUILD_DIR) $(REPO_DIR) diff --git a/desktop/flatpak/README.md b/desktop/flatpak/README.md new file mode 100644 index 00000000..98dd56d8 --- /dev/null +++ b/desktop/flatpak/README.md @@ -0,0 +1,72 @@ +# vouch Flatpak (#211) + +Sandboxed Linux install for the **vouch review console** (`vouch review-ui`). +Targets Fedora 40+, Arch, and any desktop with Flathub. + +## Quick start + +```bash +# one-time runtime (org.freedesktop.Platform//23.08 + Sdk; python3 is in the Sdk) +./scripts/install-runtime.sh + +# build + install for the current user +make install + +# launch from the app menu or: +flatpak run com.vouchdev.vouch +``` + +## Layout + +| Path | Purpose | +|---|---| +| `com.vouchdev.vouch.yaml` | flatpak-builder manifest (local dev build) | +| `com.vouchdev.vouch.desktop` | Freedesktop launcher | +| `com.vouchdev.vouch.metainfo.xml` | AppStream metadata for Flathub | +| `vouch-review-ui.sh` | `/app/bin` entrypoint | +| `share/icons/hicolor/` | App icons (16–512 + scalable SVG) | +| `flathub/` | Flathub submission JSON + checklist | +| `lib/validate.py` | Packaging validators (pytest + CLI) | +| `scripts/` | build, icon generation, screenshot capture | + +## Permissions (v1) + +| finish-arg | rationale | +|---|---| +| `--share=network` | uvicorn binds localhost; browser loads the UI | +| `--filesystem=home` | read/write `.vouch/` KB trees under `$HOME` | +| `--talk-name=org.freedesktop.portal.Desktop` | open the default browser | + +Per-KB filesystem scoping is **out of scope** for v1 — see [issue #211](https://github.com/vouchdev/vouch/issues/211). + +## Develop + +```bash +make icons # regenerate PNGs from SVG +make validate # strict manifest/desktop/metainfo checks +make requirements # sync requirements-flatpak.txt from pyproject.toml +make build # flatpak-builder only (no install) +``` + +From repo root: + +```bash +python -m pytest tests/test_flatpak.py -q +``` + +## Flathub + +After merge, follow `flathub/SUBMISSION.md` to open a Flathub application PR. +The store listing reuses metainfo screenshots under `screenshots/` — capture with +`scripts/capture-screenshots.sh` once review-ui is installed. + +Acceptance: + +- `flatpak install flathub com.vouchdev.vouch` on Fedora 40+ and Arch +- listing shows icon, description, and screenshots + +## Runtime + +- **Base:** `org.freedesktop.Platform//23.08` +- **Python:** bundled in `org.freedesktop.Sdk` (no separate Extension.python3 on Flathub) +- **Package:** `pip install '.[web]'` — no bundled CPython upgrade automation diff --git a/desktop/flatpak/com.vouchdev.vouch.desktop b/desktop/flatpak/com.vouchdev.vouch.desktop new file mode 100644 index 00000000..b3900a6a --- /dev/null +++ b/desktop/flatpak/com.vouchdev.vouch.desktop @@ -0,0 +1,22 @@ +[Desktop Entry] +Type=Application +Name=Vouch +GenericName=Review Console +Comment=Review-gated knowledge base for LLM agents +Exec=vouch-review-ui +Icon=com.vouchdev.vouch +Terminal=false +Categories=Development;Office;TextEditor; +Keywords=vouch;knowledge;review;agent;mcp;kb; +StartupNotify=true +StartupWMClass=vouch-review-ui +MimeType= +X-Flatpak-RenamedFrom=vouch.desktop; + +[Desktop Action OpenQueue] +Name=Open review queue +Exec=vouch-review-ui + +[Desktop Action OpenHeadless] +Name=Start without opening browser +Exec=vouch-review-ui --no-open-browser diff --git a/desktop/flatpak/com.vouchdev.vouch.metainfo.xml b/desktop/flatpak/com.vouchdev.vouch.metainfo.xml new file mode 100644 index 00000000..9c5acedb --- /dev/null +++ b/desktop/flatpak/com.vouchdev.vouch.metainfo.xml @@ -0,0 +1,70 @@ + + + com.vouchdev.vouch + CC0-1.0 + MIT + Vouch + Review-gated knowledge base for LLM agents + +

+ Vouch is a git-native knowledge base where every write goes through a human + review gate. Agents propose claims and pages; you approve them with the + review console or the CLI. Approved artifacts are plain YAML and markdown + on disk — diffable in PRs, auditable, and portable. +

+

+ This Flatpak ships the browser-based review console + (vouch review-ui): a local FastAPI server that lists pending + proposals, shows diffs, and records approve/reject decisions in the audit + log. Pair it with the vouch CLI or an MCP host adapter for + the full propose → review → commit loop. +

+

+ Filesystem access is limited to your home directory so vouch can read and + write .vouch/ trees under your projects and + ~/.vouch/. +

+
+ com.vouchdev.vouch.desktop + + vouchdev + + https://github.com/vouchdev/vouch + https://github.com/vouchdev/vouch/issues + https://github.com/vouchdev/vouch + https://github.com/vouchdev/vouch/blob/main/docs/getting-started.md + + + Development + Office + + + vouch + knowledge base + review + llm + mcp + agents + + + #4f5d9e + #1e2a44 + + + + Review queue — pending proposals awaiting human approval + https://raw.githubusercontent.com/vouchdev/vouch/main/desktop/flatpak/screenshots/queue.png + + + Proposal detail — diff and metadata before approve/reject + https://raw.githubusercontent.com/vouchdev/vouch/main/desktop/flatpak/screenshots/detail.png + + + + + +

Initial Flatpak packaging for the vouch review console (#211).

+
+
+
+
diff --git a/desktop/flatpak/com.vouchdev.vouch.yaml b/desktop/flatpak/com.vouchdev.vouch.yaml new file mode 100644 index 00000000..be83b8cc --- /dev/null +++ b/desktop/flatpak/com.vouchdev.vouch.yaml @@ -0,0 +1,57 @@ +# flatpak-builder manifest for vouch review-ui (#211). +# Build from this directory: +# flatpak-builder --user --install --force-clean build-dir com.vouchdev.vouch.yaml +# +# Runtime: org.freedesktop.Platform//23.08 (Fedora 40+, Arch, any Flathub desktop). +# v1 simplification: --filesystem=home covers ~/.vouch/ and project KB trees under $HOME. + +id: com.vouchdev.vouch +runtime: org.freedesktop.Platform +runtime-version: '23.08' +sdk: org.freedesktop.Sdk + +command: vouch-review-ui + +finish-args: + # uvicorn binds localhost; browser loads the review console. + - --share=network + # read/write KB dirs under the user's home (incl. ~/.vouch/). + - --filesystem=home + # xdg-open / default browser via the desktop portal. + - --talk-name=org.freedesktop.portal.Desktop + - --socket=wayland + - --socket=fallback-x11 + +modules: + # --- python application (vouch-kb + [web] extra) ---------------------------- + - name: vouch + buildsystem: simple + build-options: + env: + PIP_DISABLE_PIP_VERSION_CHECK: '1' + PYTHONNOUSERSITE: '1' + # pip resolves hatchling + runtime deps from PyPI during the build. + build-args: + - --share=network + build-commands: + # default build isolation pulls hatchling (pyproject build-system) automatically. + - pip3 install --prefix=${FLATPAK_DEST} '.[web]' + sources: + - type: dir + path: ../.. + + # --- launcher, desktop entry, metainfo, icons ------------------------------- + - name: vouch-desktop + buildsystem: simple + build-commands: + - install -Dm755 vouch-review-ui.sh ${FLATPAK_DEST}/bin/vouch-review-ui + - install -Dm644 com.vouchdev.vouch.desktop ${FLATPAK_DEST}/share/applications/com.vouchdev.vouch.desktop + - install -Dm644 com.vouchdev.vouch.metainfo.xml ${FLATPAK_DEST}/share/metainfo/com.vouchdev.vouch.metainfo.xml + - | + for size in 16 32 48 64 128 256 512; do + install -Dm644 share/icons/hicolor/${size}x${size}/apps/com.vouchdev.vouch.png \ + ${FLATPAK_DEST}/share/icons/hicolor/${size}x${size}/apps/com.vouchdev.vouch.png + done + sources: + - type: dir + path: . diff --git a/desktop/flatpak/flathub/.gitkeep b/desktop/flatpak/flathub/.gitkeep new file mode 100644 index 00000000..e7b508fa --- /dev/null +++ b/desktop/flatpak/flathub/.gitkeep @@ -0,0 +1,5 @@ +# Flathub submission helpers (#211). +# +# Flathub hosts manifests in a separate git repository per app. After this +# upstream manifest lands, open a PR against https://github.com/flathub/flathub +# with the generated com.vouchdev.vouch.json pointing at a release tag. diff --git a/desktop/flatpak/flathub/SUBMISSION.md b/desktop/flatpak/flathub/SUBMISSION.md new file mode 100644 index 00000000..77c79057 --- /dev/null +++ b/desktop/flatpak/flathub/SUBMISSION.md @@ -0,0 +1,40 @@ +# Flathub new-application checklist (#211) +# +# Use this when opening https://github.com/flathub/flathub/new-application + +## upstream + +- repository: https://github.com/vouchdev/vouch +- license: MIT +- app id: com.vouchdev.vouch + +## manifest source + +Copy `desktop/flatpak/flathub/com.vouchdev.vouch.json` into the Flathub +application repo as `com.vouchdev.vouch.json`. Replace `REPLACE_WITH_TAG_COMMIT` +with the git object for the release tag you are shipping. + +For day-to-day Flathub builds, prefer pinning `tag` + `commit` together so +rebuilds stay reproducible when tags move. + +## permissions justification (for reviewers) + +| finish-arg | why | +|---|---| +| `--share=network` | `vouch review-ui` binds a localhost port and the browser loads the UI | +| `--filesystem=home` | KB trees live under `$HOME` (project `.vouch/` dirs and optional `~/.vouch/`) | +| `--talk-name=org.freedesktop.portal.Desktop` | open the default browser to the review console | +| `--socket=wayland` / `fallback-x11` | standard GUI session integration | + +Per-KB filesystem scoping is explicitly out of scope for v1 (#211). + +## store listing assets + +- icons: `desktop/flatpak/share/icons/hicolor/*/apps/com.vouchdev.vouch.png` +- metainfo: `desktop/flatpak/com.vouchdev.vouch.metainfo.xml` +- screenshots: capture with `scripts/capture-screenshots.sh` after `flatpak run` + +## acceptance (#211) + +- `flatpak install flathub com.vouchdev.vouch` on Fedora 40+ and Arch +- Flathub listing shows icon, description, screenshots from metainfo diff --git a/desktop/flatpak/flathub/com.vouchdev.vouch.json b/desktop/flatpak/flathub/com.vouchdev.vouch.json new file mode 100644 index 00000000..853c2b14 --- /dev/null +++ b/desktop/flatpak/flathub/com.vouchdev.vouch.json @@ -0,0 +1,58 @@ +{ + "id": "com.vouchdev.vouch", + "runtime": "org.freedesktop.Platform", + "runtime-version": "23.08", + "sdk": "org.freedesktop.Sdk", + "command": "vouch-review-ui", + "finish-args": [ + "--share=network", + "--filesystem=home", + "--talk-name=org.freedesktop.portal.Desktop", + "--socket=wayland", + "--socket=fallback-x11" + ], + "modules": [ + { + "name": "vouch", + "buildsystem": "simple", + "build-options": { + "env": { + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PYTHONNOUSERSITE": "1" + }, + "build-args": [ + "--share=network" + ] + }, + "build-commands": [ + "pip3 install --prefix=${FLATPAK_DEST} '.[web]'" + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/vouchdev/vouch.git", + "tag": "v0.0.1", + "commit": "REPLACE_WITH_TAG_COMMIT" + } + ] + }, + { + "name": "vouch-desktop", + "buildsystem": "simple", + "build-commands": [ + "install -Dm755 desktop/flatpak/vouch-review-ui.sh ${FLATPAK_DEST}/bin/vouch-review-ui", + "install -Dm644 desktop/flatpak/com.vouchdev.vouch.desktop ${FLATPAK_DEST}/share/applications/com.vouchdev.vouch.desktop", + "install -Dm644 desktop/flatpak/com.vouchdev.vouch.metainfo.xml ${FLATPAK_DEST}/share/metainfo/com.vouchdev.vouch.metainfo.xml", + "for size in 16 32 48 64 128 256 512; do install -Dm644 desktop/flatpak/share/icons/hicolor/${size}x${size}/apps/com.vouchdev.vouch.png ${FLATPAK_DEST}/share/icons/hicolor/${size}x${size}/apps/com.vouchdev.vouch.png; done" + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/vouchdev/vouch.git", + "tag": "v0.0.1", + "commit": "REPLACE_WITH_TAG_COMMIT" + } + ] + } + ] +} diff --git a/desktop/flatpak/lib/__init__.py b/desktop/flatpak/lib/__init__.py new file mode 100644 index 00000000..59ee6933 --- /dev/null +++ b/desktop/flatpak/lib/__init__.py @@ -0,0 +1 @@ +"""Flatpak packaging support code (#211).""" diff --git a/desktop/flatpak/lib/validate.py b/desktop/flatpak/lib/validate.py new file mode 100644 index 00000000..a60018c7 --- /dev/null +++ b/desktop/flatpak/lib/validate.py @@ -0,0 +1,320 @@ +"""Flatpak packaging validation helpers (#211). + +Pure-stdlib checks used by tests and ``scripts/validate-manifest.py``. Keeps +the manifest, desktop entry, metainfo, and icon tree aligned with Flathub +expectations without requiring flatpak-builder at pytest time. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path + +FLATPAK_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = FLATPAK_DIR.parents[1] + +MANIFEST_NAME = "com.vouchdev.vouch.yaml" +APP_ID = "com.vouchdev.vouch" +RUNTIME = "org.freedesktop.Platform" +RUNTIME_VERSION = "23.08" +COMMAND = "vouch-review-ui" + +REQUIRED_FINISH_ARGS = frozenset( + { + "--share=network", + "--filesystem=home", + } +) + +RECOMMENDED_FINISH_ARGS = frozenset( + { + "--talk-name=org.freedesktop.portal.Desktop", + "--socket=wayland", + "--socket=fallback-x11", + } +) + +ICON_SIZES = (16, 32, 48, 64, 128, 256, 512) + +DESKTOP_REQUIRED_KEYS = frozenset( + { + "Type", + "Name", + "Exec", + "Icon", + } +) + + +@dataclass +class ValidationIssue: + level: str # error | warning | info + message: str + path: str | None = None + + +@dataclass +class ValidationReport: + issues: list[ValidationIssue] = field(default_factory=list) + + @property + def errors(self) -> list[ValidationIssue]: + return [i for i in self.issues if i.level == "error"] + + @property + def warnings(self) -> list[ValidationIssue]: + return [i for i in self.issues if i.level == "warning"] + + def ok(self) -> bool: + return not self.errors + + def add(self, level: str, message: str, path: str | None = None) -> None: + self.issues.append(ValidationIssue(level=level, message=message, path=path)) + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _parse_simple_yaml_list(block: str, key: str) -> list[str]: + """Minimal YAML list parser for flatpak manifest keys (no PyYAML dep).""" + lines = block.splitlines() + in_key = False + items: list[str] = [] + for line in lines: + if re.match(rf"^{re.escape(key)}:\s*$", line): + in_key = True + continue + if in_key: + m = re.match(r"^\s+-\s+(.+)$", line) + if m: + items.append(m.group(1).strip()) + continue + if line and not line.startswith(" "): + break + return items + + +def _manifest_scalar(block: str, key: str) -> str | None: + m = re.search(rf"^{re.escape(key)}:\s*(.+)$", block, re.MULTILINE) + return m.group(1).strip().strip("'\"") if m else None + + +def validate_manifest_exists(report: ValidationReport, root: Path = FLATPAK_DIR) -> Path: + path = root / MANIFEST_NAME + if not path.is_file(): + report.add("error", f"missing manifest {MANIFEST_NAME}", str(path)) + return path + report.add("info", "manifest present", str(path)) + return path + + +def validate_manifest_content(report: ValidationReport, root: Path = FLATPAK_DIR) -> None: + path = root / MANIFEST_NAME + if not path.is_file(): + return + text = _read_text(path) + + app_id = _manifest_scalar(text, "id") + if app_id != APP_ID: + report.add("error", f"id must be {APP_ID!r}, got {app_id!r}", str(path)) + + runtime = _manifest_scalar(text, "runtime") + if runtime != RUNTIME: + report.add("error", f"runtime must be {RUNTIME!r}, got {runtime!r}", str(path)) + + runtime_version = _manifest_scalar(text, "runtime-version") + if runtime_version != RUNTIME_VERSION: + report.add( + "error", + f"runtime-version must be {RUNTIME_VERSION!r}, got {runtime_version!r}", + str(path), + ) + + command = _manifest_scalar(text, "command") + if command != COMMAND: + report.add("error", f"command must be {COMMAND!r}, got {command!r}", str(path)) + + finish_args = set(_parse_simple_yaml_list(text, "finish-args")) + missing_required = REQUIRED_FINISH_ARGS - finish_args + for arg in sorted(missing_required): + report.add("error", f"finish-args missing required {arg}", str(path)) + + missing_recommended = RECOMMENDED_FINISH_ARGS - finish_args + for arg in sorted(missing_recommended): + report.add("warning", f"finish-args missing recommended {arg}", str(path)) + + if "org.freedesktop.Sdk.Extension.python3" in text: + report.add( + "error", + "do not use Sdk.Extension.python3 — python3 ships in org.freedesktop.Sdk", + str(path), + ) + + if "/usr/lib/sdk/python3" in text: + report.add( + "error", + "do not append-path /usr/lib/sdk/python3 — use the SDK's built-in pip3", + str(path), + ) + + if "exclude:" in text and "type: dir" in text: + report.add( + "warning", + "dir sources do not support exclude on older flatpak-builder; " + "use a git source or staging module instead", + str(path), + ) + + if "name: vouch" not in text: + report.add("error", "modules must include vouch python module", str(path)) + + if "name: vouch-desktop" not in text: + report.add("error", "modules must include vouch-desktop assets module", str(path)) + + if ".[web]" not in text: + report.add("error", "pip install must include [web] extra for review-ui", str(path)) + + if "name: vouch" in text and "build-args:" in text and "--share=network" not in text: + report.add( + "error", + "vouch module build needs --share=network for pip/PyPI during flatpak build", + str(path), + ) + + if "--no-build-isolation" in text and "hatchling" not in text: + report.add( + "warning", + "--no-build-isolation requires hatchling pre-installed in the build sandbox", + str(path), + ) + + +def validate_desktop_entry(report: ValidationReport, root: Path = FLATPAK_DIR) -> None: + path = root / f"{APP_ID}.desktop" + if not path.is_file(): + report.add("error", "missing .desktop launcher", str(path)) + return + + text = _read_text(path) + keys: dict[str, str] = {} + for line in text.splitlines(): + if line.startswith("[") or not line.strip() or line.strip().startswith("#"): + continue + if "=" in line: + k, v = line.split("=", 1) + keys[k.strip()] = v.strip() + + for req in DESKTOP_REQUIRED_KEYS: + if req not in keys: + report.add("error", f".desktop missing [{req}]", str(path)) + + if keys.get("Icon") != APP_ID: + report.add("error", f".desktop Icon must be {APP_ID}", str(path)) + + if keys.get("Exec", "").split()[0] != COMMAND: + report.add("error", f".desktop Exec must start with {COMMAND}", str(path)) + + +def validate_metainfo(report: ValidationReport, root: Path = FLATPAK_DIR) -> None: + path = root / f"{APP_ID}.metainfo.xml" + if not path.is_file(): + report.add("error", "missing AppStream metainfo", str(path)) + return + + try: + tree = ET.parse(path) + except ET.ParseError as e: + report.add("error", f"metainfo XML parse error: {e}", str(path)) + return + + root_el = tree.getroot() + component_id = root_el.findtext("id") + if component_id != APP_ID: + report.add("error", f" must be {APP_ID}, got {component_id!r}", str(path)) + + launchable = root_el.find("launchable") + if launchable is None or launchable.get("type") != "desktop-id": + report.add("error", " required", str(path)) + elif launchable.text != f"{APP_ID}.desktop": + report.add("error", "launchable desktop-id must match .desktop file", str(path)) + + if root_el.find("name") is None: + report.add("error", " required for Flathub", str(path)) + + if root_el.find("summary") is None: + report.add("error", " required for Flathub", str(path)) + + releases = root_el.findall("releases/release") + if not releases: + report.add("warning", "no — Flathub needs versioned releases", str(path)) + else: + for rel in releases: + if not rel.get("version"): + report.add("error", " missing version attribute", str(path)) + if not rel.get("date"): + report.add("warning", f"release {rel.get('version')} missing date", str(path)) + + screenshots = root_el.findall("screenshots/screenshot") + if not screenshots: + report.add("warning", "no screenshots — Flathub listing needs at least one", str(path)) + + +def validate_icons(report: ValidationReport, root: Path = FLATPAK_DIR) -> None: + scalable = ( + root / "share/icons/hicolor/scalable/apps" / f"{APP_ID}.svg" + ) + if scalable.is_file(): + report.add("info", "scalable SVG icon present", str(scalable)) + else: + report.add("warning", "no scalable SVG icon", str(scalable)) + + for size in ICON_SIZES: + png = ( + root + / "share/icons/hicolor" + / f"{size}x{size}" + / "apps" + / f"{APP_ID}.png" + ) + if not png.is_file(): + report.add("error", f"missing {size}x{size} PNG icon", str(png)) + elif png.stat().st_size < 64: + report.add("error", f"{size}x{size} PNG looks truncated", str(png)) + + +def validate_launcher_script(report: ValidationReport, root: Path = FLATPAK_DIR) -> None: + path = root / "vouch-review-ui.sh" + if not path.is_file(): + report.add("error", "missing vouch-review-ui.sh launcher", str(path)) + return + text = _read_text(path) + if "vouch review-ui" not in text: + report.add("error", "launcher must invoke vouch review-ui", str(path)) + if not text.startswith("#!/"): + report.add("warning", "launcher should have a shebang", str(path)) + + +def validate_requirements_pin(report: ValidationReport, root: Path = FLATPAK_DIR) -> None: + path = root / "requirements-flatpak.txt" + if not path.is_file(): + report.add("warning", "requirements-flatpak.txt missing", str(path)) + return + text = _read_text(path) + for dep in ("fastapi", "uvicorn", "pydantic", "mcp"): + if dep not in text: + report.add("error", f"requirements-flatpak.txt must pin {dep}", str(path)) + + +def run_all_validations(root: Path = FLATPAK_DIR) -> ValidationReport: + report = ValidationReport() + validate_manifest_exists(report, root) + validate_manifest_content(report, root) + validate_desktop_entry(report, root) + validate_metainfo(report, root) + validate_icons(report, root) + validate_launcher_script(report, root) + validate_requirements_pin(report, root) + return report diff --git a/desktop/flatpak/requirements-flatpak.txt b/desktop/flatpak/requirements-flatpak.txt new file mode 100644 index 00000000..9d2cacaa --- /dev/null +++ b/desktop/flatpak/requirements-flatpak.txt @@ -0,0 +1,13 @@ +# Pinned runtime dependencies for the Flatpak [web] install path (#211). +# Mirrors pyproject.toml core + web extras; regenerate with: +# desktop/flatpak/scripts/generate-requirements.py + +pydantic>=2.13.4,<3 +click>=8.4.0,<9 +pyyaml>=6,<7 +mcp>=1.0,<2 +fastapi>=0.115,<1 +jinja2>=3,<4 +python-multipart>=0.0.9 +uvicorn>=0.30,<1 +websockets>=12,<16 diff --git a/desktop/flatpak/screenshots/README.md b/desktop/flatpak/screenshots/README.md new file mode 100644 index 00000000..680565e0 --- /dev/null +++ b/desktop/flatpak/screenshots/README.md @@ -0,0 +1,4 @@ +# Placeholder screenshots for Flathub metainfo (#211). +# +# Replace with real captures from `scripts/capture-screenshots.sh` before +# submitting to Flathub. Metainfo URLs point here on main. diff --git a/desktop/flatpak/scripts/build-flatpak.sh b/desktop/flatpak/scripts/build-flatpak.sh new file mode 100644 index 00000000..a9184a1b --- /dev/null +++ b/desktop/flatpak/scripts/build-flatpak.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Build and optionally install the vouch Flatpak locally (#211). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="${ROOT}/com.vouchdev.vouch.yaml" +BUILD_DIR="${ROOT}/build-dir" +REPO_DIR="${ROOT}/repo" + +usage() { + cat <<'EOF' +Usage: build-flatpak.sh [--install] [--run] [--clean] + + --install install the build into the user Flatpak repo + --run run com.vouchdev.vouch after a successful build + --clean remove build-dir/ and repo/ before building +EOF +} + +INSTALL=0 +RUN=0 +CLEAN=0 +while [ $# -gt 0 ]; do + case "$1" in + --install) INSTALL=1 ;; + --run) RUN=1 ;; + --clean) CLEAN=1 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown arg: $1" >&2; usage; exit 1 ;; + esac + shift +done + +command -v flatpak-builder >/dev/null || { + echo "flatpak-builder not found; install flatpak-builder" >&2 + exit 1 +} + +python3 "${ROOT}/scripts/generate-icons.py" +python3 "${ROOT}/scripts/validate-manifest.py" --strict + +if [ "$CLEAN" = 1 ]; then + rm -rf "${BUILD_DIR}" "${REPO_DIR}" +fi + +ARGS=(--force-clean --repo="${REPO_DIR}" "${BUILD_DIR}" "${MANIFEST}") +if [ "$INSTALL" = 1 ]; then + ARGS=(--user --install "${ARGS[@]}") +fi + +flatpak-builder "${ARGS[@]}" + +if [ "$RUN" = 1 ]; then + flatpak run com.vouchdev.vouch +fi diff --git a/desktop/flatpak/scripts/capture-screenshots.sh b/desktop/flatpak/scripts/capture-screenshots.sh new file mode 100644 index 00000000..ed03ea8e --- /dev/null +++ b/desktop/flatpak/scripts/capture-screenshots.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Capture review-ui screenshots for Flathub metainfo (#211). +# +# Prerequisites: +# - built/installed flatpak: ./scripts/build-flatpak.sh --install +# - a sample KB with pending proposals (or empty queue is fine for smoke) +# - imagemagick `import` or gnome-screenshot / spectacle available +# +# Output: desktop/flatpak/screenshots/{queue,detail}.png + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="${ROOT}/screenshots" +mkdir -p "${OUT}" + +PORT="${VOUCH_SCREENSHOT_PORT:-7780}" +URL="http://127.0.0.1:${PORT}/" + +echo "Starting vouch review-ui on ${URL} (flatpak)…" +flatpak run --command=vouch com.vouchdev.vouch review-ui \ + --bind "127.0.0.1:${PORT}" \ + --no-open-browser & +PID=$! +trap 'kill ${PID} 2>/dev/null || true' EXIT + +for _ in $(seq 1 30); do + if curl -fsS "${URL}" >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +if command -v import >/dev/null; then + import -window root "${OUT}/queue.png" +else + echo "install imagemagick or capture ${URL} manually into ${OUT}/" >&2 + exit 1 +fi + +echo "wrote ${OUT}/queue.png" +echo "open a proposal detail view and re-run with VOUCH_SCREENSHOT=detail for the second shot" diff --git a/desktop/flatpak/scripts/generate-icons.py b/desktop/flatpak/scripts/generate-icons.py new file mode 100644 index 00000000..411a7453 --- /dev/null +++ b/desktop/flatpak/scripts/generate-icons.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Generate hicolor PNG icons from the scalable SVG (#211). + +Uses only the Python standard library (zlib + struct). Run from repo root: + + python desktop/flatpak/scripts/generate-icons.py +""" + +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + +FLATPAK_DIR = Path(__file__).resolve().parents[1] +SVG_PATH = FLATPAK_DIR / "share/icons/hicolor/scalable/apps/com.vouchdev.vouch.svg" +APP_ID = "com.vouchdev.vouch" +SIZES = (16, 32, 48, 64, 128, 256, 512) + +# Brand palette from docs/banner.svg +COLOR_BG_TOP = (0xF1, 0xF3, 0xF7) +COLOR_BG_BOTTOM = (0xE2, 0xE6, 0xF3) +COLOR_BAR_TOP = (0x4F, 0x5D, 0x9E) +COLOR_BAR_BOTTOM = (0x1E, 0x2A, 0x44) +COLOR_TEXT = (0x1E, 0x2A, 0x44) +COLOR_ACCENT = (0x4F, 0x5D, 0x9E) + + +def _lerp(a: int, b: int, t: float) -> int: + return int(a + (b - a) * t) + + +def _lerp_rgb( + c1: tuple[int, int, int], c2: tuple[int, int, int], t: float +) -> tuple[int, int, int]: + return (_lerp(c1[0], c2[0], t), _lerp(c1[1], c2[1], t), _lerp(c1[2], c2[2], t)) + + +def _render_icon(size: int) -> list[tuple[int, int, int]]: + """Rasterize a simplified vector motif matching the SVG.""" + pixels: list[tuple[int, int, int]] = [] + radius = size * 96 / 512 + bar_x = size * 108 / 512 + bar_w = size * 56 / 512 + bar_y = size * 96 / 512 + bar_h = size * 320 / 512 + circle_cx = size * 392 / 512 + circle_cy = size * 360 / 512 + circle_r = size * 28 / 512 + + for y in range(size): + for x in range(size): + t = (x + y) / (2 * (size - 1)) if size > 1 else 0.0 + bg = _lerp_rgb(COLOR_BG_TOP, COLOR_BG_BOTTOM, t) + + # rounded rect background clip (approx) + dx = min(x, size - 1 - x) + dy = min(y, size - 1 - y) + corner = radius + outside = (dx < corner and dy < corner) and ( + (corner - dx) ** 2 + (corner - dy) ** 2 > corner**2 + ) + if outside: + pixels.append((0, 0, 0)) + continue + + color = bg + + # vertical bar with gradient + if bar_x <= x <= bar_x + bar_w and bar_y <= y <= bar_y + bar_h: + bar_t = (y - bar_y) / bar_h if bar_h else 0 + color = _lerp_rgb(COLOR_BAR_TOP, COLOR_BAR_BOTTOM, bar_t) + + # 'v' stem (simplified block letter) + text_x0 = size * 200 / 512 + text_x1 = size * 310 / 512 + text_y0 = size * 170 / 512 + text_y1 = size * 310 / 512 + if text_x0 <= x <= text_x1 and text_y0 <= y <= text_y1: + color = COLOR_TEXT + + # review circle accent + dist = ((x - circle_cx) ** 2 + (y - circle_cy) ** 2) ** 0.5 + if circle_r - size * 6 / 512 <= dist <= circle_r: + color = COLOR_ACCENT + + pixels.append(color) + return pixels + + +def _write_png(path: Path, size: int, pixels: list[tuple[int, int, int]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + raw = bytearray() + for y in range(size): + raw.append(0) # filter type None + start = y * size + for x in range(size): + r, g, b = pixels[start + x] + raw.extend((r, g, b)) + + ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) + png = b"\x89PNG\r\n\x1a\n" + png += chunk(b"IHDR", ihdr) + png += chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + png += chunk(b"IEND", b"") + path.write_bytes(png) + + +def main() -> None: + if not SVG_PATH.is_file(): + raise SystemExit(f"scalable icon missing: {SVG_PATH}") + + for size in SIZES: + pixels = _render_icon(size) + out = ( + FLATPAK_DIR + / "share/icons/hicolor" + / f"{size}x{size}" + / "apps" + / f"{APP_ID}.png" + ) + _write_png(out, size, pixels) + print(f"wrote {out} ({out.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/desktop/flatpak/scripts/generate-requirements.py b/desktop/flatpak/scripts/generate-requirements.py new file mode 100644 index 00000000..a318e425 --- /dev/null +++ b/desktop/flatpak/scripts/generate-requirements.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Regenerate requirements-flatpak.txt from pyproject.toml (#211).""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +PYPROJECT = REPO_ROOT / "pyproject.toml" +OUT = Path(__file__).resolve().parents[1] / "requirements-flatpak.txt" + +HEADER = """\ +# Pinned runtime dependencies for the Flatpak [web] install path (#211). +# Mirrors pyproject.toml core + web extras; regenerate with: +# desktop/flatpak/scripts/generate-requirements.py + +""" + + +def main() -> None: + text = PYPROJECT.read_text(encoding="utf-8") + # project.dependencies is under [project] not optional + proj = re.search(r"dependencies = \[(.*?)\]", text, re.DOTALL) + deps: list[str] = [] + if proj: + deps.extend(re.findall(r'"([^"]+)"', proj.group(1))) + web_block = re.search( + r"web = \[(.*?)\]", + text, + re.DOTALL, + ) + if web_block: + deps.extend(re.findall(r'"([^"]+)"', web_block.group(1))) + + # de-dupe preserving order + seen: set[str] = set() + unique: list[str] = [] + for d in deps: + if d not in seen: + seen.add(d) + unique.append(d) + + OUT.write_text(HEADER + "\n".join(unique) + "\n", encoding="utf-8") + print(f"wrote {OUT} ({len(unique)} deps)") + + +if __name__ == "__main__": + main() diff --git a/desktop/flatpak/scripts/install-runtime.sh b/desktop/flatpak/scripts/install-runtime.sh new file mode 100644 index 00000000..614d53ec --- /dev/null +++ b/desktop/flatpak/scripts/install-runtime.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Install org.freedesktop.Platform//23.08 + SDK for local flatpak builds (#211). +set -euo pipefail + +RUNTIME_VERSION="${FLATPAK_RUNTIME_VERSION:-23.08}" + +flatpak install -y --user flathub \ + "org.freedesktop.Platform//${RUNTIME_VERSION}" \ + "org.freedesktop.Sdk//${RUNTIME_VERSION}" + +echo "runtime ${RUNTIME_VERSION} ready (python3 ships in the SDK)" diff --git a/desktop/flatpak/scripts/validate-manifest.py b/desktop/flatpak/scripts/validate-manifest.py new file mode 100644 index 00000000..6508c1e8 --- /dev/null +++ b/desktop/flatpak/scripts/validate-manifest.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Validate flatpak packaging files (#211). + +Exit 0 when no errors; 1 when validation fails. Warnings print but do not fail +unless --strict. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Allow running as a script without installing the package. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from lib.validate import run_all_validations + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate vouch Flatpak packaging") + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="desktop/flatpak directory", + ) + parser.add_argument( + "--strict", + action="store_true", + help="treat warnings as errors", + ) + args = parser.parse_args() + + report = run_all_validations(args.root) + for issue in report.issues: + prefix = issue.level.upper() + loc = f" ({issue.path})" if issue.path else "" + print(f"{prefix}: {issue.message}{loc}") + + if report.errors: + return 1 + if args.strict and report.warnings: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktop/flatpak/share/icons/hicolor/128x128/apps/com.vouchdev.vouch.png b/desktop/flatpak/share/icons/hicolor/128x128/apps/com.vouchdev.vouch.png new file mode 100644 index 00000000..ad7009ac Binary files /dev/null and b/desktop/flatpak/share/icons/hicolor/128x128/apps/com.vouchdev.vouch.png differ diff --git a/desktop/flatpak/share/icons/hicolor/16x16/apps/com.vouchdev.vouch.png b/desktop/flatpak/share/icons/hicolor/16x16/apps/com.vouchdev.vouch.png new file mode 100644 index 00000000..5cad70db Binary files /dev/null and b/desktop/flatpak/share/icons/hicolor/16x16/apps/com.vouchdev.vouch.png differ diff --git a/desktop/flatpak/share/icons/hicolor/256x256/apps/com.vouchdev.vouch.png b/desktop/flatpak/share/icons/hicolor/256x256/apps/com.vouchdev.vouch.png new file mode 100644 index 00000000..0a408298 Binary files /dev/null and b/desktop/flatpak/share/icons/hicolor/256x256/apps/com.vouchdev.vouch.png differ diff --git a/desktop/flatpak/share/icons/hicolor/32x32/apps/com.vouchdev.vouch.png b/desktop/flatpak/share/icons/hicolor/32x32/apps/com.vouchdev.vouch.png new file mode 100644 index 00000000..5ee8ef14 Binary files /dev/null and b/desktop/flatpak/share/icons/hicolor/32x32/apps/com.vouchdev.vouch.png differ diff --git a/desktop/flatpak/share/icons/hicolor/48x48/apps/com.vouchdev.vouch.png b/desktop/flatpak/share/icons/hicolor/48x48/apps/com.vouchdev.vouch.png new file mode 100644 index 00000000..5ac7a39a Binary files /dev/null and b/desktop/flatpak/share/icons/hicolor/48x48/apps/com.vouchdev.vouch.png differ diff --git a/desktop/flatpak/share/icons/hicolor/512x512/apps/com.vouchdev.vouch.png b/desktop/flatpak/share/icons/hicolor/512x512/apps/com.vouchdev.vouch.png new file mode 100644 index 00000000..581a4d6d Binary files /dev/null and b/desktop/flatpak/share/icons/hicolor/512x512/apps/com.vouchdev.vouch.png differ diff --git a/desktop/flatpak/share/icons/hicolor/64x64/apps/com.vouchdev.vouch.png b/desktop/flatpak/share/icons/hicolor/64x64/apps/com.vouchdev.vouch.png new file mode 100644 index 00000000..aacbe6b2 Binary files /dev/null and b/desktop/flatpak/share/icons/hicolor/64x64/apps/com.vouchdev.vouch.png differ diff --git a/desktop/flatpak/share/icons/hicolor/scalable/apps/com.vouchdev.vouch.svg b/desktop/flatpak/share/icons/hicolor/scalable/apps/com.vouchdev.vouch.svg new file mode 100644 index 00000000..438b63aa --- /dev/null +++ b/desktop/flatpak/share/icons/hicolor/scalable/apps/com.vouchdev.vouch.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + v + + + diff --git a/desktop/flatpak/vouch-review-ui.sh b/desktop/flatpak/vouch-review-ui.sh new file mode 100644 index 00000000..18183202 --- /dev/null +++ b/desktop/flatpak/vouch-review-ui.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Flatpak entrypoint for the vouch review console (#211). +# +# Runs the local uvicorn server and opens the system browser via the desktop +# portal. KB discovery uses the same rules as the CLI: nearest .vouch/ above +# cwd, or pass --kb / set VOUCH_KB_PATH. + +set -eu + +BIND="${VOUCH_REVIEW_BIND:-127.0.0.1:7780}" + +if [ -n "${VOUCH_KB_PATH:-}" ]; then + exec vouch review-ui --bind "${BIND}" --kb "${VOUCH_KB_PATH}" "$@" +fi + +exec vouch review-ui --bind "${BIND}" "$@" diff --git a/docs/desktop-flatpak.md b/docs/desktop-flatpak.md new file mode 100644 index 00000000..e231c649 --- /dev/null +++ b/docs/desktop-flatpak.md @@ -0,0 +1,43 @@ +# Flatpak desktop install (#211) + +Linux users on Fedora, Arch, and other Flathub-enabled desktops can install the +vouch **review console** as a sandboxed Flatpak instead of pipx. + +## Install (once on Flathub) + +```bash +flatpak install flathub com.vouchdev.vouch +flatpak run com.vouchdev.vouch +``` + +Until the Flathub listing is live, build from source — see +[`desktop/flatpak/README.md`](../desktop/flatpak/README.md). + +## What the package contains + +The Flatpak ships `vouch review-ui`: a local FastAPI server plus browser UI for +approving or rejecting agent proposals. It does **not** replace the full CLI or +MCP server — pair it with `pipx install vouch-kb` or a host adapter for the +propose side of the loop. + +## Filesystem access + +v1 grants `--filesystem=home` so vouch can discover and mutate `.vouch/` trees +under your home directory (project checkouts and optional `~/.vouch/`). Narrower +per-KB permissions may come in a later release. + +## Network + +`--share=network` allows binding `127.0.0.1` for the review UI and loading it +in your browser. The app does not phone home. + +## Packaging source + +All manifests, icons, and validators live under `desktop/flatpak/`. CI runs +`tests/test_flatpak.py` on every PR to keep the manifest aligned with issue +#211 acceptance criteria. + +## Related + +- [Getting started](./getting-started.md) — agent + human review loop +- [GitHub issue #211](https://github.com/vouchdev/vouch/issues/211) — tracking diff --git a/pyproject.toml b/pyproject.toml index b82bb865..1bff8962 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,14 @@ web = [ # channel; without it the WebSocket handshake 500s at runtime. "websockets>=12,<16", ] +desktop = [ + # Alias for review-ui / Flatpak packaging (#211). Same runtime deps as [web]. + "fastapi>=0.115,<1", + "jinja2>=3,<4", + "python-multipart>=0.0.9", + "uvicorn>=0.30,<1", + "websockets>=12,<16", +] dev = [ "pytest>=9.0.3,<10", "pytest-cov>=5,<6", diff --git a/tests/test_flatpak.py b/tests/test_flatpak.py new file mode 100644 index 00000000..c4278633 --- /dev/null +++ b/tests/test_flatpak.py @@ -0,0 +1,146 @@ +"""Flatpak packaging contract checks (#211).""" + +from __future__ import annotations + +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +FLATPAK_DIR = REPO_ROOT / "desktop" / "flatpak" + +APP_ID = "com.vouchdev.vouch" +MANIFEST = FLATPAK_DIR / "com.vouchdev.vouch.yaml" + + +@pytest.fixture +def manifest_text() -> str: + return MANIFEST.read_text(encoding="utf-8") + + +def test_flatpak_directory_layout() -> None: + expected = [ + MANIFEST, + FLATPAK_DIR / "com.vouchdev.vouch.desktop", + FLATPAK_DIR / "com.vouchdev.vouch.metainfo.xml", + FLATPAK_DIR / "vouch-review-ui.sh", + FLATPAK_DIR / "requirements-flatpak.txt", + FLATPAK_DIR / "share/icons/hicolor/scalable/apps/com.vouchdev.vouch.svg", + FLATPAK_DIR / "flathub/com.vouchdev.vouch.json", + ] + for path in expected: + assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}" + + +def test_manifest_runtime_and_permissions(manifest_text: str) -> None: + assert f"id: {APP_ID}" in manifest_text + assert "runtime: org.freedesktop.Platform" in manifest_text + assert ( + "runtime-version: '23.08'" in manifest_text + or 'runtime-version: "23.08"' in manifest_text + ) + assert "command: vouch-review-ui" in manifest_text + assert "--share=network" in manifest_text + assert "--filesystem=home" in manifest_text + + +def test_manifest_installs_web_extra(manifest_text: str) -> None: + assert ".[web]" in manifest_text + assert "name: vouch-desktop" in manifest_text + assert "--share=network" in manifest_text + assert "build-args:" in manifest_text + + +def test_desktop_entry_points_at_launcher() -> None: + text = (FLATPAK_DIR / f"{APP_ID}.desktop").read_text(encoding="utf-8") + assert "Exec=vouch-review-ui" in text + assert f"Icon={APP_ID}" in text + + +def test_metainfo_id_and_launchable() -> None: + tree = ET.parse(FLATPAK_DIR / f"{APP_ID}.metainfo.xml") + root = tree.getroot() + assert root.findtext("id") == APP_ID + launch = root.find("launchable") + assert launch is not None + assert launch.get("type") == "desktop-id" + assert launch.text == f"{APP_ID}.desktop" + + +def test_metainfo_has_release() -> None: + tree = ET.parse(FLATPAK_DIR / f"{APP_ID}.metainfo.xml") + releases = tree.getroot().findall("releases/release") + assert releases, "Flathub needs at least one release" + assert releases[0].get("version") + + +def test_icon_png_sizes() -> None: + sizes = (16, 32, 48, 64, 128, 256, 512) + for size in sizes: + png = ( + FLATPAK_DIR + / "share/icons/hicolor" + / f"{size}x{size}" + / "apps" + / f"{APP_ID}.png" + ) + assert png.is_file(), f"missing icon {size}x{size}" + assert png.stat().st_size > 64 + + +def test_launcher_invokes_review_ui() -> None: + text = (FLATPAK_DIR / "vouch-review-ui.sh").read_text(encoding="utf-8") + assert "vouch review-ui" in text + + +def test_flathub_json_matches_app_id() -> None: + data = json.loads((FLATPAK_DIR / "flathub/com.vouchdev.vouch.json").read_text()) + assert data["id"] == APP_ID + assert data["runtime-version"] == "23.08" + finish = set(data["finish-args"]) + assert "--filesystem=home" in finish + assert "--share=network" in finish + + +def test_requirements_cover_web_stack() -> None: + text = (FLATPAK_DIR / "requirements-flatpak.txt").read_text(encoding="utf-8") + for dep in ("fastapi", "uvicorn", "websockets", "mcp", "pydantic"): + assert dep in text + + +def test_validate_module_reports_clean() -> None: + import sys + + sys.path.insert(0, str(FLATPAK_DIR)) + from lib.validate import run_all_validations + + report = run_all_validations(FLATPAK_DIR) + assert report.ok(), [f"{i.level}: {i.message}" for i in report.errors] + + +def test_manifest_sources_repo_root(manifest_text: str) -> None: + assert "type: dir" in manifest_text + assert "path: ../.." in manifest_text + + +def test_issue_211_acceptance_documented() -> None: + readme = (FLATPAK_DIR / "README.md").read_text(encoding="utf-8") + assert "23.08" in readme + assert "filesystem=home" in readme or "--filesystem=home" in readme + assert "flathub" in readme.lower() + + +def test_generate_icons_script_is_idempotent() -> None: + script = FLATPAK_DIR / "scripts/generate-icons.py" + assert script.is_file() + # script path referenced in Makefile + makefile = (FLATPAK_DIR / "Makefile").read_text(encoding="utf-8") + assert "generate-icons.py" in makefile + + +def test_flathub_submission_checklist_mentions_commit_pin() -> None: + text = (FLATPAK_DIR / "flathub/SUBMISSION.md").read_text(encoding="utf-8") + assert "REPLACE_WITH_TAG_COMMIT" in text + assert "flatpak install flathub" in text diff --git a/tests/test_flatpak_validate_extended.py b/tests/test_flatpak_validate_extended.py new file mode 100644 index 00000000..7a1dfeed --- /dev/null +++ b/tests/test_flatpak_validate_extended.py @@ -0,0 +1,85 @@ +"""Extended flatpak validation tests (#211).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +FLATPAK_DIR = REPO_ROOT / "desktop" / "flatpak" +sys.path.insert(0, str(FLATPAK_DIR)) + +from lib.validate import ( # noqa: E402 + APP_ID, + ICON_SIZES, + REQUIRED_FINISH_ARGS, + ValidationReport, + validate_desktop_entry, + validate_icons, + validate_launcher_script, + validate_manifest_content, + validate_metainfo, + validate_requirements_pin, +) + + +@pytest.fixture +def report() -> ValidationReport: + return ValidationReport() + + +def test_required_finish_args_frozen() -> None: + assert "--share=network" in REQUIRED_FINISH_ARGS + assert "--filesystem=home" in REQUIRED_FINISH_ARGS + + +def test_manifest_content_clean(report: ValidationReport) -> None: + validate_manifest_content(report, FLATPAK_DIR) + assert not report.errors + + +def test_desktop_entry_clean(report: ValidationReport) -> None: + validate_desktop_entry(report, FLATPAK_DIR) + assert not report.errors + + +def test_metainfo_clean(report: ValidationReport) -> None: + validate_metainfo(report, FLATPAK_DIR) + assert not report.errors + + +def test_icons_all_sizes(report: ValidationReport) -> None: + validate_icons(report, FLATPAK_DIR) + assert not report.errors + assert len(ICON_SIZES) == 7 + + +def test_launcher_clean(report: ValidationReport) -> None: + validate_launcher_script(report, FLATPAK_DIR) + assert not report.errors + + +def test_requirements_clean(report: ValidationReport) -> None: + validate_requirements_pin(report, FLATPAK_DIR) + assert not report.errors + + +def test_app_id_matches_flathub_json() -> None: + import json + + data = json.loads((FLATPAK_DIR / "flathub/com.vouchdev.vouch.json").read_text()) + assert data["id"] == APP_ID + + +@pytest.mark.parametrize("size", ICON_SIZES) +def test_each_icon_png_magic(size: int) -> None: + path = ( + FLATPAK_DIR + / "share/icons/hicolor" + / f"{size}x{size}" + / "apps" + / f"{APP_ID}.png" + ) + assert path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"