diff --git a/.github/workflows/release-cef-host.yml b/.github/workflows/release-cef-host.yml deleted file mode 100644 index ca22a13..0000000 --- a/.github/workflows/release-cef-host.yml +++ /dev/null @@ -1,88 +0,0 @@ -# Publish a prebuilt cef_host.app when a `cef-host-v*` tag is pushed, and update the committed -# manifest (cef_host_prebuilt.json) so consumers' `pod install` fetches it. Uses only GITHUB_TOKEN -# — NO signing secrets: the published artifact is ad-hoc signed; consumers re-sign their own release -# builds with their own Developer-ID. See specs/prebuilt-cef-host/PLAN.md. -# -# TODO (follow-ups): add an x86_64 matrix leg (needs an Intel/Rosetta runner — cross-arch sign is -# unproven) and a hardened "release-compiled" variant (CEF_HOST_ADHOC=OFF drops the dev mock-keychain -# / Mach-port bypass) once the signing path is wired. -name: release-cef-host - -on: - push: - tags: ['cef-host-v*'] - workflow_dispatch: - inputs: - tag: - description: 'Existing cef-host-v* tag to (re)publish for' - required: true - -permissions: - contents: write # upload release assets + commit the manifest back - -jobs: - arm64: - runs-on: macos-14 # Apple Silicon - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.tag || github.ref_name }} - - - name: Build cef_host (ad-hoc, dev variant) - run: | - CEF_HOST_ADHOC=ON bash packages/flutter_cef_macos/native/build_cef_host.sh "$RUNNER_TEMP/out" - test -d "$RUNNER_TEMP/out/cef_host.app" - - - name: Package + checksum (with provenance) - id: pkg - run: | - TAG="${{ github.event.inputs.tag || github.ref_name }}" - SRC_SHA="$(git rev-parse HEAD)" - CEF_VER="$(grep '^CEF_VERSION=' packages/flutter_cef_macos/native/build_cef_host.sh | head -1 | cut -d'"' -f2)" - FILE="cef_host-macos-arm64-dev.tar.gz" - cd "$RUNNER_TEMP/out" - echo "$SRC_SHA" > cef_host_source_sha.txt - echo "$CEF_VER" > cef_version.txt - tar -czf "$RUNNER_TEMP/$FILE" cef_host.app cef_host_source_sha.txt cef_version.txt - SHA="$(shasum -a 256 "$RUNNER_TEMP/$FILE" | awk '{print $1}')" - echo "$SHA $FILE" > "$RUNNER_TEMP/$FILE.sha256" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "file=$FILE" >> "$GITHUB_OUTPUT" - echo "sha=$SHA" >> "$GITHUB_OUTPUT" - echo "src=$SRC_SHA" >> "$GITHUB_OUTPUT" - echo "cef=$CEF_VER" >> "$GITHUB_OUTPUT" - - - name: Upload to the release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release upload "${{ steps.pkg.outputs.tag }}" \ - "$RUNNER_TEMP/${{ steps.pkg.outputs.file }}" \ - "$RUNNER_TEMP/${{ steps.pkg.outputs.file }}.sha256" \ - --repo "${{ github.repository }}" --clobber - - - name: Update committed manifest on the default branch - env: - GH_TOKEN: ${{ github.token }} - run: | - DEF="${{ github.event.repository.default_branch }}" - git fetch origin "$DEF" - git checkout "$DEF" - python3 - "$DEF" "${{ steps.pkg.outputs.tag }}" "${{ steps.pkg.outputs.file }}" \ - "${{ steps.pkg.outputs.sha }}" "${{ steps.pkg.outputs.src }}" "${{ steps.pkg.outputs.cef }}" <<'PY' - import json, sys - _def, tag, file, sha, src, cef = sys.argv[1:7] - p = "packages/flutter_cef_macos/cef_host_prebuilt.json" - m = json.load(open(p)) - m["version"] = tag - m["cef_version"] = cef - m["source_sha"] = src - m["base_url"] = f"https://github.com/${{ github.repository }}/releases/download/{tag}" - m.setdefault("artifacts", {})["macos-arm64-dev"] = {"file": file, "sha256": sha} - json.dump(m, open(p, "w"), indent=2); open(p, "a").write("\n") - PY - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/flutter_cef_macos/cef_host_prebuilt.json - git commit -m "chore(cef): publish prebuilt cef_host ${{ steps.pkg.outputs.tag }} [skip ci]" || echo "manifest already current" - git push origin "$DEF" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0aedd4a --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +# flutter_cef — developer convenience targets. +# +# publish-cef-host: build the SANDBOXED (CEF_HOST_ADHOC=OFF), Developer-ID cef_host +# and publish it to GCS keyed by a content hash of the build inputs. Run this when +# native/cef_host/ or the CEF version changes so consumers can fetch a matching host +# (fetch_cef_host.sh) instead of building from source. Idempotent — re-running with +# an unchanged host is a no-op. Needs a Developer ID Application identity in your +# keychain and gsutil write on gs://flutterflow-downloads (auto-resolves the +# identity; override CODESIGN_ID / GCS_PREFIX to customize). +# +# make publish-cef-host +# GCS_PREFIX=campus_prebuilt_cef_host-staging make publish-cef-host # dry-run to staging + +CODESIGN_ID ?= $(shell security find-identity -v -p codesigning | grep 'Developer ID Application' | head -1 | awk '{print $$2}') + +.PHONY: publish-cef-host +publish-cef-host: + @test -n "$(CODESIGN_ID)" || { echo "error: no 'Developer ID Application' identity in the keychain"; exit 1; } + @command -v gsutil >/dev/null 2>&1 || { echo "error: gsutil not found (install the Google Cloud SDK)"; exit 1; } + CODESIGN_ID="$(CODESIGN_ID)" bash packages/flutter_cef_macos/tool/publish-cef-host.sh diff --git a/packages/flutter_cef_macos/cef_host_prebuilt.json b/packages/flutter_cef_macos/cef_host_prebuilt.json deleted file mode 100644 index 081ca0b..0000000 --- a/packages/flutter_cef_macos/cef_host_prebuilt.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "v0.2.0", - "cef_version": "144.0.27+g3fae261+chromium-144.0.7559.254", - "source_sha": "3f38477928b6b6ce5831348fad2d3f06a3d58214", - "base_url": "https://github.com/FlutterFlow/flutter_cef/releases/download/cef-host-v0.2.0", - "artifacts": { - "macos-arm64-dev": { - "file": "cef_host-macos-arm64-dev.tar.gz", - "sha256": "a9eaceeb06a25097ddae8ee573b662b419753bc822ef06c15cc615c08d802f52" - } - } -} diff --git a/packages/flutter_cef_macos/tool/cef_host_hash.sh b/packages/flutter_cef_macos/tool/cef_host_hash.sh new file mode 100755 index 0000000..95db4ad --- /dev/null +++ b/packages/flutter_cef_macos/tool/cef_host_hash.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Deterministic content hash of the cef_host build inputs. +# +# Sourced by BOTH fetch_cef_host.sh (consumer, at pod install) and +# publish-cef-host.sh (CI) so they ALWAYS compute the same digest from the same +# source tree — that digest is the GCS object key, so any drift here is a silent +# cache miss. Prints a 64-hex digest to stdout. +# +# Inputs = native/build_cef_host.sh (carries CEF_VERSION + the CEF dist sha pin + +# the signing/adhoc defaults) + every source file under native/cef_host/ +# (excluding the prebuilt/ and build/ OUTPUT dirs). The huge CEF binary dist is +# NOT hashed — it is pinned transitively by build_cef_host.sh's CEF_VERSION. +# +# Usage: . cef_host_hash.sh ; cef_host_input_hash +# = .../packages/flutter_cef_macos/native +set -euo pipefail + +# sha256 of the given file(s), or of stdin when no args. Portable across bash/zsh +# (no reliance on word-splitting a command string) and macOS/Linux. +_cefhost_sha256() { + if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$@"; else sha256sum "$@"; fi +} + +cef_host_input_hash() { + local native_dir="$1" + # Sorted, path-relative list of build-input files. LC_ALL=C makes the sort + # byte-stable across machines. We emit "\n\n" per file so + # BOTH content changes and path/renames move the final digest. + ( + cd "$native_dir" + export LC_ALL=C + { + printf '%s\n' "build_cef_host.sh" + find cef_host -type f \ + -not -path 'cef_host/prebuilt/*' \ + -not -path 'cef_host/build/*' + } | LC_ALL=C sort -u | while IFS= read -r f; do + printf '%s\n' "$f" + _cefhost_sha256 "$f" | awk '{print $1}' + done + ) | _cefhost_sha256 | awk '{print $1}' +} diff --git a/packages/flutter_cef_macos/tool/fetch_cef_host.sh b/packages/flutter_cef_macos/tool/fetch_cef_host.sh index 724cfe7..50ef9b8 100755 --- a/packages/flutter_cef_macos/tool/fetch_cef_host.sh +++ b/packages/flutter_cef_macos/tool/fetch_cef_host.sh @@ -1,72 +1,82 @@ #!/usr/bin/env bash -# Fetch the prebuilt, version-matched cef_host.app — run at `pod install` via the podspec's -# prepare_command. Downloads + SHA256-verifies the artifact named in cef_host_prebuilt.json, -# caches it, and extracts cef_host.app into native/cef_host/prebuilt/ where the :after_compile -# script phase embeds it. Self-locating (CWD-independent). Fail-OPEN: any problem leaves no -# prebuilt, and the build falls back to FLUTTER_CEF_HOST / build-from-source. +# Fetch a prebuilt, Developer-ID-signed cef_host.app keyed by a CONTENT HASH of +# the build inputs, from public GCS. Runs at `pod install` via the podspec's +# prepare_command; the :after_compile phase then embeds native/cef_host/prebuilt/ +# cef_host.app into the app's Contents/Frameworks. Self-locating (CWD-independent). # -# Escape hatch: FLUTTER_CEF_FROM_SOURCE=1 skips the fetch entirely (co-dev builds cef_host from -# source via native/build_cef_host.sh and points the app at it with $FLUTTER_CEF_HOST). -set -uo pipefail +# The hash (see cef_host_hash.sh) is derived from the checked-out native/cef_host +# sources + build_cef_host.sh, so it is identical to what the publisher computed — +# no committed manifest, no per-commit bookkeeping, release-model-agnostic (any +# SHA/branch/tag pin that checks out the same native sources resolves to the same +# object). Fail-OPEN on network/missing (co-dev + offline builds fall back to +# build-from-source / FLUTTER_CEF_HOST); fail-CLOSED on checksum mismatch. +set -euo pipefail +# Escape hatch: co-dev / build-from-source (native/build_cef_host.sh + a make host). if [ -n "${FLUTTER_CEF_FROM_SOURCE:-}" ]; then - echo "[flutter_cef] FLUTTER_CEF_FROM_SOURCE set — skipping prebuilt fetch (build-from-source)" + echo "[flutter_cef] FLUTTER_CEF_FROM_SOURCE set — skipping prebuilt fetch (build from source)." exit 0 fi -HERE="$(cd "$(dirname "$0")" && pwd)" # .../flutter_cef_macos/tool -PKG="$(cd "$HERE/.." && pwd)" # .../flutter_cef_macos -MANIFEST="$PKG/cef_host_prebuilt.json" -DEST="$PKG/native/cef_host/prebuilt" - -[ -f "$MANIFEST" ] || { echo "[flutter_cef] no $MANIFEST — skipping fetch"; exit 0; } +HERE="$(cd "$(dirname "$0")" && pwd)" # .../tool +PKG="$(cd "$HERE/.." && pwd)" # .../flutter_cef_macos +NATIVE="$PKG/native" +DEST="$NATIVE/cef_host/prebuilt" +# Only macos-arm64 is published today; x86_64 builds from source. case "$(uname -m)" in - arm64) arch=arm64 ;; - x86_64) arch=x86_64 ;; - *) echo "[flutter_cef] unsupported arch $(uname -m) — skipping fetch"; exit 0 ;; + arm64) arch=arm64 ;; + *) echo "[flutter_cef] arch $(uname -m) has no prebuilt cef_host — build from source."; exit 0 ;; esac -key="macos-${arch}-dev" -# Parse the manifest with python3 (present on every macOS dev box). Prints: base file sha src ver -read -r base file sha src ver </dev/null)" = "$src" ]; then - echo "[flutter_cef] prebuilt cef_host already current ($src) — skipping fetch" +# Already current? the extracted prebuilt carries the input hash it was built from. +STAMP="$DEST/cef_host_input_hash.txt" +if [ -d "$DEST/cef_host.app" ] && [ -f "$STAMP" ] && [ "$(cat "$STAMP")" = "$HASH" ]; then + echo "[flutter_cef] prebuilt cef_host.app already current ($HASH) — skipping fetch." exit 0 fi -CACHE="${FLUTTER_CEF_CACHE:-$HOME/.cache/flutter_cef}/prebuilt/$src/$arch" +CACHE="${FLUTTER_CEF_CACHE:-$HOME/.cache/flutter_cef}/prebuilt/$HASH/$arch" mkdir -p "$CACHE" -tarball="$CACHE/$file" +tarball="$CACHE/$FILE" + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + else sha256sum "$1" | awk '{print $1}'; fi +} + +# The expected tarball sha256 (transport integrity) lives beside the object. +# Fail-OPEN if unreachable: no published host for this hash yet (a fresh native +# change before CI publishes, or offline) -> build from source. +expected="" +if ! expected="$(curl -fsSL --retry 3 --retry-delay 1 "$SHA_URL" 2>/dev/null | awk '{print $1}')"; then + echo "[flutter_cef] no published cef_host for hash $HASH ($SHA_URL unreachable)." + echo "[flutter_cef] building from source (dev), or CI will publish it shortly." + exit 0 +fi -if [ ! -f "$tarball" ] || [ "$(shasum -a 256 "$tarball" 2>/dev/null | awk '{print $1}')" != "$sha" ]; then - echo "[flutter_cef] downloading prebuilt cef_host ($key, cef $ver)…" - if ! curl -fL --retry 3 "$base/$file" -o "$tarball.part"; then - echo "[flutter_cef] download failed — leaving no prebuilt (FLUTTER_CEF_HOST / from-source will be used)" >&2 - rm -f "$tarball.part"; exit 0 +# (Re)download on cache miss or a stale/corrupt cached tarball. +if [ ! -f "$tarball" ] || [ "$(sha256_file "$tarball")" != "$expected" ]; then + echo "[flutter_cef] downloading prebuilt cef_host: $URL" + if ! curl -fL --retry 3 --retry-delay 1 -o "$tarball.part" "$URL"; then + echo "[flutter_cef] download failed — building from source." >&2 + rm -f "$tarball.part" + exit 0 fi - got="$(shasum -a 256 "$tarball.part" | awk '{print $1}')" - if [ "$got" != "$sha" ]; then - echo "[flutter_cef] SHA256 mismatch (got $got, want $sha) — refusing the artifact" >&2 - rm -f "$tarball.part"; exit 1 + actual="$(sha256_file "$tarball.part")" + if [ "$actual" != "$expected" ]; then + echo "[flutter_cef] SHA256 mismatch for $FILE (expected $expected, got $actual) — refusing." >&2 + rm -f "$tarball.part" + exit 1 # fail-CLOSED: never embed an unverified host fi mv "$tarball.part" "$tarball" fi @@ -74,5 +84,8 @@ fi echo "[flutter_cef] extracting prebuilt cef_host -> $DEST" mkdir -p "$DEST" rm -rf "$DEST/cef_host.app" +# tar preserves the inside-out Developer-ID signature; the .app + provenance +# stamps (source_sha / version / input_hash) land in prebuilt/. tar -xzf "$tarball" -C "$DEST" -echo "[flutter_cef] prebuilt cef_host ready ($src)" +printf '%s\n' "$HASH" > "$STAMP" # stamp even if the tarball predates the field +echo "[flutter_cef] prebuilt cef_host ready ($HASH)." diff --git a/packages/flutter_cef_macos/tool/publish-cef-host.sh b/packages/flutter_cef_macos/tool/publish-cef-host.sh new file mode 100755 index 0000000..48710b7 --- /dev/null +++ b/packages/flutter_cef_macos/tool/publish-cef-host.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Build the SANDBOXED (CEF_HOST_ADHOC=OFF, Developer-ID) cef_host, key it by a +# content hash of the build inputs, and idempotently publish it to public GCS. +# Run by the private flutter_cef Codemagic workflow (which holds the signing +# material + a GCS-writable service account) on push-to-main and cef-host-v* tags. +# +# The SANDBOXED variant is deliberate: the ad-hoc variant (get-task-allow + mock +# keychain + Mach-port bypass) fails to render agent_ui in a consuming app. The +# Developer-ID signature is inside-out; release consumers re-sign it with their +# own identity, so only the (rare) direct-run case depends on it. +# +# Requires: gsutil/gcloud authed with object-create on gs://$GCS_BUCKET, and a +# Developer-ID Application identity in the keychain named by $CODESIGN_ID. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" # .../tool +PKG="$(cd "$HERE/.." && pwd)" # .../flutter_cef_macos +NATIVE="$PKG/native" +REPO="$(cd "$PKG/../.." && pwd)" # repo root (git provenance) + +: "${CODESIGN_ID:?CODESIGN_ID (Developer ID Application identity) must be set}" +GCS_BUCKET="${GCS_BUCKET:-flutterflow-downloads}" +GCS_PREFIX="${GCS_PREFIX:-campus_prebuilt_cef_host}" +arch=arm64 +FILE="cef_host-macos-${arch}.tar.gz" + +# shellcheck source=cef_host_hash.sh +. "$HERE/cef_host_hash.sh" +HASH="$(cef_host_input_hash "$NATIVE")" +echo "[publish] cef_host input hash: $HASH" + +DST="gs://$GCS_BUCKET/$GCS_PREFIX/$HASH/$FILE" + +# Idempotency: this exact tree was already built + uploaded -> nothing to do. +if gsutil -q stat "$DST" 2>/dev/null; then + echo "[publish] $DST already exists — nothing to do." + exit 0 +fi + +# --- Build the sandboxed, Developer-ID-signed variant --- +OUT="$(mktemp -d)/out" +mkdir -p "$OUT" +CEF_HOST_ADHOC=OFF CODESIGN_ID="$CODESIGN_ID" bash "$NATIVE/build_cef_host.sh" "$OUT" +APP="$OUT/cef_host.app" +[ -d "$APP" ] || { echo "::error:: cef_host.app not produced by build" >&2; exit 1; } + +# Fail-fast: it must be Developer-ID signed and NOT ad-hoc. Capture the output +# (|| true) and string-match rather than piping into grep under `set -o pipefail` +# — codesign -dvv can exit non-zero on a perfectly valid signature, which would +# false-fail a `codesign … | grep` pipeline. +sig="$(codesign -dvv "$APP" 2>&1 || true)" +case "$sig" in + *"Developer ID Application"*) : ;; + *) echo "::error:: cef_host.app is not Developer-ID signed (ad-hoc?):" >&2 + printf '%s\n' "$sig" | head -3 >&2 + exit 1 ;; +esac + +# --- Provenance stamps beside the app (informational; the URL is the hash) --- +SRC_SHA="$(git -C "$REPO" rev-parse HEAD)" +CEF_VER="$(grep '^CEF_VERSION=' "$NATIVE/build_cef_host.sh" | head -1 | cut -d'"' -f2)" +printf '%s\n' "$SRC_SHA" > "$OUT/cef_host_source_sha.txt" +printf '%s\n' "$CEF_VER" > "$OUT/cef_version.txt" +printf '%s\n' "$HASH" > "$OUT/cef_host_input_hash.txt" + +# --- Tar + sha256 (COPYFILE_DISABLE keeps ._* AppleDouble junk out of the tar) --- +STAGE="$(mktemp -d)" +TARBALL="$STAGE/$FILE" +COPYFILE_DISABLE=1 tar -czf "$TARBALL" -C "$OUT" \ + cef_host.app cef_host_source_sha.txt cef_version.txt cef_host_input_hash.txt +if command -v shasum >/dev/null 2>&1; then + TAR_SHA="$(shasum -a 256 "$TARBALL" | awk '{print $1}')" +else + TAR_SHA="$(sha256sum "$TARBALL" | awk '{print $1}')" +fi +printf '%s %s\n' "$TAR_SHA" "$FILE" > "$TARBALL.sha256" + +# --- Upload (re-check to close a publish race; objects are immutable) --- +if gsutil -q stat "$DST" 2>/dev/null; then + echo "[publish] $DST appeared during build — skipping upload." + exit 0 +fi +gsutil -h "Cache-Control:public,max-age=31536000,immutable" cp "$TARBALL" "$DST" +gsutil -h "Cache-Control:public,max-age=31536000,immutable" cp "$TARBALL.sha256" "$DST.sha256" +echo "[publish] uploaded $DST (tarball sha256 $TAR_SHA)" diff --git a/specs/prebuilt-cef-host/PLAN.md b/specs/prebuilt-cef-host/PLAN.md index 092753a..3780b7f 100644 --- a/specs/prebuilt-cef-host/PLAN.md +++ b/specs/prebuilt-cef-host/PLAN.md @@ -1,5 +1,27 @@ # Prebuilt, auto-bundled cef_host — make flutter_cef "just a Flutter package" +## Status — content-hash + GCS + Codemagic (supersedes the manifest/GitHub-release model) + +The prebuilt is now **keyed by a content hash of the build inputs** (`native/cef_host/` + +`build_cef_host.sh`) and served from **public GCS** — not by a committed manifest + ad-hoc +GitHub release. Why: the artifact decouples from how flutter_cef versions itself (any +SHA/branch/tag pin that checks out the same native sources resolves to the same object), a +Dart-only change rebuilds nothing, and the signing material never touches this public repo. +The published variant is the **sandboxed, Developer-ID** host (`CEF_HOST_ADHOC=OFF`) — the +ad-hoc variant fails to render `agent_ui` in a consumer, which is why the earlier +manifest/ad-hoc approach was not adopted downstream. + +- `tool/cef_host_hash.sh` — the deterministic hash, sourced by both sides so they can't drift. +- `tool/fetch_cef_host.sh` — consumer fetch: hash → `https://storage.googleapis.com/flutterflow-downloads/campus_prebuilt_cef_host//cef_host-macos-arm64.tar.gz`, sha256-verify, extract. Fail-open on network, fail-closed on mismatch. +- `tool/publish-cef-host.sh` — build sandboxed+signed → hash → idempotent GCS upload. CI-agnostic. +- `make publish-cef-host` — the current publisher: run it locally when `cef_host` changes + (auto-resolves the Developer-ID identity; needs `gsutil` write). Since the host changes + rarely, this is enough; `publish-cef-host.sh` is CI-ready if automation is wanted later + (e.g. a step in a repo you control that already holds signing + GCS creds). + +The design detail below (embedding, signing, incremental rollout) still stands; only the +publish/fetch transport changed from "committed manifest + `gh release`" to "content-hash + GCS". + ## Problem The Dart/Swift half of flutter_cef is already a normal pod. The native `cef_host.app` (a nested SIGNED app: ~200MB Chromium + 5 helper apps) is NOT — every consumer must