diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index a6733dd..4475d01 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -565,6 +565,24 @@ class CefWebController { Future navigate(String url) => _channel.invokeMethod('navigate', {'sessionId': sessionId, 'url': url}); + /// Open [url] in a windowed Chrome-runtime browser for a WebAuthn / passkey + /// sign-in ceremony (Touch ID, account picker, hybrid QR). + /// + /// The OSR tile itself cannot host WebAuthn — `navigator.credentials.create/get` + /// hangs because there is no window for the system sheet to attach to. This pops + /// a real Chrome-runtime window that CAN, sharing this tile's cookie jar so the + /// sign-in propagates back to the tile once the window is closed. Typically + /// called with the tile's current address ([url]'s live value). + Future openAuthWindow(String url) { + // Defense-in-depth (mirrored authoritatively in the Swift session): only ever + // open the cookie-bearing auth window at a real web origin. Refuse non-http(s) + // schemes (javascript:/data:/file:/about: ...) rather than round-tripping them. + final scheme = Uri.tryParse(url)?.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'http') return Future.value(); + return _channel + .invokeMethod('openAuthWindow', {'sessionId': sessionId, 'url': url}); + } + /// CEF-2a — enable agent control for this tile and return a brokered, token-gated /// CDP endpoint a standard CDP client (e.g. `agent-browser`) can connect to. /// diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift index 7914549..af17455 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift @@ -39,7 +39,7 @@ final class CefProfileHost { // processGone) instead of silently mis-parsing frames into frozen/blank tiles; the // skew vectors are FLUTTER_CEF_HOST overrides, stale from-source builds, and stale // embedded copies (the content-hash fetch can't drift on the normal path). - static let protocolVersion: UInt8 = 3 + static let protocolVersion: UInt8 = 4 // Profile identity / config. let profileId: String diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift index 18b2e31..a381c9f 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift @@ -71,6 +71,7 @@ final class CefWebSession: NSObject, FlutterTexture { private static let opShowDevTools: UInt8 = 0x33 private static let opLoadTrusted: UInt8 = 0x34 private static let opSetVisible: UInt8 = 0x35 + private static let opOpenAuthWindow: UInt8 = 0x39 // Event callbacks (fired off the main thread). The registrar relays each to a // Dart channel message. @@ -348,6 +349,22 @@ final class CefWebSession: NSObject, FlutterTexture { sendFrame(Self.opNavigate, Array(url.utf8)) } + /// Open a windowed Chrome-runtime browser at |url| for a WebAuthn / Touch ID + /// ceremony the OSR tile cannot host. Shares this session's cookie jar. + /// + /// Defense-in-depth: this pops a cookie-bearing, user-navigable top-level window + /// outside OSR containment, so only ever launch it at a real web origin. Reject + /// anything that isn't http(s) (javascript:/data:/file:/about: ...) -- the tile's + /// own `navigate` is scheme-gated in cef_host; keep this entry point in step. + func openAuthWindow(_ url: String) { + guard let scheme = URL(string: url)?.scheme?.lowercased(), + scheme == "https" || scheme == "http" else { + NSLog("[flutter_cef] openAuthWindow refused non-http(s) URL") + return + } + sendFrame(Self.opOpenAuthWindow, Array(url.utf8)) + } + /// A host content-injection load (loadHtmlString -> data:, loadFile -> file:): /// exempt from the navigation scheme allowlist, unlike `navigate`. func loadTrusted(_ url: String) { diff --git a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift index 85e2adb..de1c4b0 100644 --- a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift +++ b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift @@ -113,6 +113,7 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin { switch call.method { case "create": create(args, result) case "navigate": navigate(args, result) + case "openAuthWindow": openAuthWindow(args, result) case "loadTrusted": loadTrusted(args, result) case "resize": resize(args, result) case "getFrameSurface": getFrameSurface(args, result) @@ -621,6 +622,16 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin { result(nil) } + /// Open a windowed Chrome-runtime browser at |url| for a WebAuthn / Touch ID + /// ceremony the OSR tile cannot host. Shares the session's cookie jar so the + /// sign-in propagates back to the tile. + private func openAuthWindow(_ a: [String: Any], _ result: @escaping FlutterResult) { + if let id = a["sessionId"] as? String, let url = a["url"] as? String { + sessions[id]?.openAuthWindow(url) + } + result(nil) + } + /// Host content-injection load (loadHtmlString/loadFile) — bypasses the /// navigation scheme allowlist in cef_host. private func loadTrusted(_ a: [String: Any], _ result: @escaping FlutterResult) { diff --git a/packages/flutter_cef_macos/native/build-cef-from-source.sh b/packages/flutter_cef_macos/native/build-cef-from-source.sh new file mode 100755 index 0000000..0220954 --- /dev/null +++ b/packages/flutter_cef_macos/native/build-cef-from-source.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Build a PATCHED CEF binary distribution from source (macOS arm64), carrying the +# Campus WebAuthn keychain-access-group patch so cef_host can use the on-device +# platform authenticator (Touch ID / Secure Enclave) for passkeys. +# +# WHY: OSR/Alloy-style windowless browsers can't host WebAuthn UI, so the sign-in +# ceremony runs in a windowed Chrome-runtime browser. On-device Touch ID there is +# gated by the keychain-access-group Chromium names +# ".org.chromium.Chromium.webauthn" -- but UNBRANDED CEF leaves the team +# prefix EMPTY (".org.chromium.Chromium.webauthn"), which no valid entitlement can +# match. The prebuilt CEF exposes no API/switch for it, so the only fix is a +# from-source rebuild with native/patches/campus_webauthn_keychain.patch. +# Full write-up: work_canvas specs/cef-passkey/PLAN.md. +# +# This is Tier-2 (build libcef from source), NOT a Chromium fork -- fold it into +# the same from-source build you stand up for proprietary codecs. +# +# Usage: DOWNLOAD_DIR=/Volumes/CEFBuild/cef_src/chromium_git ./build-cef-from-source.sh +# Output: $DOWNLOAD_DIR/chromium/src/cef/binary_distrib/cef_binary__macosarm64_minimal +# -> point build_cef_host.sh at it via FLUTTER_CEF_CACHE (see bottom). +# +# Requirements (learned the hard way): +# - FULL Xcode (not just CLT). Xcode 26 split out the Metal toolchain: run +# xcodebuild -downloadComponent MetalToolchain +# once, or ANGLE shader compilation fails ("missing Metal Toolchain"). +# - ~150 GB free on a CASE-SENSITIVE volume (Chromium checkout can hit case +# collisions on case-insensitive APFS). 32 GB+ RAM. +# - Network access to chromium.googlesource.com (first checkout ~70 GB). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" + +# Pinned to match the prebuilt cef_host wrapper's CEF API (keep in lockstep with +# native/build_cef_host.sh CEF_VERSION). +CEF_VERSION="144.0.27+g3fae261+chromium-144.0.7559.254" +CEF_COMMIT="3fae261" # exact CEF commit (= g3fae261 in the version) +CEF_BRANCH="7559" # Chromium branch base position (= 144.0.7559.x) +CEF_NAME="cef_binary_${CEF_VERSION}_macosarm64_minimal" + +DOWNLOAD_DIR="${DOWNLOAD_DIR:?set DOWNLOAD_DIR to a case-sensitive path with ~150GB free}" +SRC="$DOWNLOAD_DIR/chromium/src" +AUTOMATE="$DOWNLOAD_DIR/../automate/automate-git.py" +PATCH="$HERE/patches/campus_webauthn_keychain.patch" + +[ -f "$PATCH" ] || { echo "missing patch: $PATCH" >&2; exit 1; } + +# 0. Bootstrap automate-git.py if absent. +if [ ! -f "$AUTOMATE" ]; then + echo "[cef-src] fetching automate-git.py" + mkdir -p "$(dirname "$AUTOMATE")" + curl -fsSL \ + "https://raw.githubusercontent.com/chromiumembedded/cef/master/tools/automate/automate-git.py" \ + -o "$AUTOMATE" +fi + +# 1. Checkout Chromium + CEF at the exact pinned revs, apply CEF's stock patches, +# but DO NOT build yet (so we can inject our patch first). +echo "[cef-src] checkout (Chromium ${CEF_VERSION}) -- first run downloads ~70GB" +python3 "$AUTOMATE" \ + --download-dir="$DOWNLOAD_DIR" \ + --branch="$CEF_BRANCH" --checkout="$CEF_COMMIT" \ + --arm64-build --no-build --no-distrib + +# 2. Apply the Campus keychain patch to the Chromium tree (idempotent). +# Applied directly via `git apply` -- it is NOT registered in CEF's patch.cfg, +# so a stock/unpatched build is unaffected purely via the content-hash + variant +# marker machinery (tool/cef_host_hash.sh + CEF_FRAMEWORK_VARIANT), not via any +# patch-cycle condition. +if git -C "$SRC" apply --check --reverse "$PATCH" 2>/dev/null; then + echo "[cef-src] keychain patch already applied" +else + echo "[cef-src] applying keychain patch" + git -C "$SRC" apply "$PATCH" +fi + +# 3. Build RELEASE arm64 with is_official_build=true. This flag is load-bearing +# THREE ways, do NOT downgrade it to a bare dcheck_always_on=false build: +# (a) it matches exactly how the stock Spotify prebuilt framework is built; +# (b) it strips DCHECKs -- a plain from-source build enables them and CRASHES +# on startup at chrome_paths_mac.mm (Chrome's framework-layout DCHECK is +# incompatible with CEF's Versions/A layout); +# (c) it FIXES Google-account caBLE/QR passkey sign-in. A non-official build +# breaks it ("Something went wrong" after the email step, post-passkey); +# the official build completes the flow (reaches Google's passkey +# enrollment page). NB: the -67030 process_requirement log line is a +# BENIGN red herring -- present in the working build too; don't chase it. +# proprietary_codecs + ffmpeg_branding="Chrome" add H.264/AAC. These are +# ROYALTY-BEARING (MPEG-LA / Via-LA) -- gate *distribution* (prod release-tag) +# on legal sign-off. See work_canvas specs/cef-passkey/PLAN.md. +echo "[cef-src] build (Release arm64, official + H.264/AAC) -- multi-hour first compile" +GN_DEFINES='is_official_build=true proprietary_codecs=true ffmpeg_branding="Chrome"' \ + python3 "$AUTOMATE" \ + --download-dir="$DOWNLOAD_DIR" \ + --branch="$CEF_BRANCH" --checkout="$CEF_COMMIT" \ + --arm64-build --no-debug-build \ + --no-update --force-build --no-distrib + +# 4. Package the minimal distrib. --no-symbols is REQUIRED: --no-debug-build means +# no .dSYM, and the default make_distrib dies packaging symbols. --no-format +# avoids a clang-format dependency. +echo "[cef-src] make_distrib (minimal, no symbols)" +python3 "$SRC/cef/tools/make_distrib.py" \ + --output-dir="$SRC/cef/binary_distrib" \ + --ninja-build --arm64-build --minimal --no-symbols --no-format --no-docs + +DIST="$SRC/cef/binary_distrib/$CEF_NAME" +echo "[cef-src] DONE -> $DIST" +echo +echo "Verify the patch made it into the framework:" +echo " strings -a '$DIST/Release/Chromium Embedded Framework.framework/Chromium Embedded Framework' | grep KLAJ5X6PJP.org.chromium.Chromium.webauthn" +echo +echo "Build cef_host against it (drop-in for the Spotify prebuilt):" +echo " mkdir -p /tmp/cef_cache && ln -sfn '$DIST' /tmp/cef_cache/$CEF_NAME" +echo " FLUTTER_CEF_CACHE=/tmp/cef_cache CEF_HOST_ADHOC=OFF \\" +echo " CODESIGN_ID='Developer ID Application: FlutterFlow, Inc. (KLAJ5X6PJP)' \\" +echo " '$HERE/build_cef_host.sh'" +echo +echo "cef_host then needs (see specs/cef-passkey/PLAN.md): the keychain-access-group" +echo "entitlement on the BROWSER process only + an embedded Developer-ID" +echo "provisioning profile authorizing that group. Release Campus.app is already" +echo "signed+notarized, so the validation-category condition is satisfied there." diff --git a/packages/flutter_cef_macos/native/build_cef_host.sh b/packages/flutter_cef_macos/native/build_cef_host.sh index 4ff715f..914a7b8 100755 --- a/packages/flutter_cef_macos/native/build_cef_host.sh +++ b/packages/flutter_cef_macos/native/build_cef_host.sh @@ -50,6 +50,38 @@ if [ ! -f "$CEF_ROOT/cmake/FindCEF.cmake" ]; then tar -xjf "$tarball" -C "$CACHE" fi +# Campus framework-variant guard. If the pinned variant (native/cef_host/ +# CEF_FRAMEWORK_VARIANT) is not 'stock', the CEF framework at CEF_ROOT MUST be the +# PATCHED from-source build (provided by pointing FLUTTER_CEF_CACHE at the output +# of native/build-cef-from-source.sh). Fail loudly rather than silently linking +# the stock Spotify framework under a patched content hash -- the exact silent +# drift cef_host_hash.sh's variant fold-in guards against. +CEF_VARIANT="stock" +[ -f "$HERE/cef_host/CEF_FRAMEWORK_VARIANT" ] && \ + CEF_VARIANT="$(tr -d '[:space:]' < "$HERE/cef_host/CEF_FRAMEWORK_VARIANT")" +[ -z "$CEF_VARIANT" ] && CEF_VARIANT="stock" +if [ "$CEF_VARIANT" != "stock" ]; then + echo "[flutter_cef] CEF framework variant: $CEF_VARIANT (expecting a PATCHED from-source framework)" + case "$CEF_VARIANT" in + *webauthn*) + _fw="$(find "$CEF_ROOT/Release" -name 'Chromium Embedded Framework' -type f 2>/dev/null | head -1)" + # grep -c (not -q): -q exits early and SIGPIPEs strings, which under + # `set -o pipefail` makes the pipeline fail even on a MATCH (false negative). + _fw_hits=0 + [ -n "$_fw" ] && _fw_hits="$(LC_ALL=C strings -a "$_fw" 2>/dev/null \ + | grep -c 'KLAJ5X6PJP\.org\.chromium\.Chromium\.webauthn')" + if [ -z "$_fw" ] || [ "${_fw_hits:-0}" -eq 0 ]; then + echo "[flutter_cef] FATAL: variant '$CEF_VARIANT' expects the WebAuthn keychain patch, but the" >&2 + echo " framework at $CEF_ROOT does not contain it." >&2 + echo " Build the patched framework with native/build-cef-from-source.sh and point" >&2 + echo " FLUTTER_CEF_CACHE at its binary_distrib output before running this script." >&2 + exit 1 + fi + echo "[flutter_cef] verified: WebAuthn keychain patch present in framework" + ;; + esac +fi + echo "[flutter_cef] building cef_host.app ..." MP_FLAG="-DCEF_MULTI_PROCESS=ON" # default: renders heavy SPAs + crash-isolated if [ "${CEF_MULTI_PROCESS:-}" = "0" ] || [ "${CEF_MULTI_PROCESS:-}" = "OFF" ]; then diff --git a/packages/flutter_cef_macos/native/cef_host/CEF_FRAMEWORK_VARIANT b/packages/flutter_cef_macos/native/cef_host/CEF_FRAMEWORK_VARIANT new file mode 100644 index 0000000..4f1f5ce --- /dev/null +++ b/packages/flutter_cef_macos/native/cef_host/CEF_FRAMEWORK_VARIANT @@ -0,0 +1 @@ +campus-webauthn-h264 diff --git a/packages/flutter_cef_macos/native/cef_host/entitlements.browser.plist b/packages/flutter_cef_macos/native/cef_host/entitlements.browser.plist new file mode 100644 index 0000000..c8f116e --- /dev/null +++ b/packages/flutter_cef_macos/native/cef_host/entitlements.browser.plist @@ -0,0 +1,30 @@ + + + + + + com.apple.security.cs.allow-jit + com.apple.security.device.bluetooth + + keychain-access-groups + + KLAJ5X6PJP.org.chromium.Chromium.webauthn + + + com.apple.application-identifier + KLAJ5X6PJP.io.flutterflow.campus.dev.mac + com.apple.developer.team-identifier + KLAJ5X6PJP + + diff --git a/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist b/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist index 5865459..1db35f5 100644 --- a/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist +++ b/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist @@ -23,5 +23,10 @@ com.apple.security.device.bluetooth + diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index 0c1c392..cb55af9 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -105,7 +105,7 @@ // stale embedded copy). BUMP THIS on any semantic change to the kOp wire protocol // below, together with CefProfileHost.protocolVersion (Swift side) — the two must // stay equal. Hosts predating the handshake send a 1-byte payload and read as v0. -constexpr uint8_t kCefHostProtocolVersion = 3; +constexpr uint8_t kCefHostProtocolVersion = 4; // ---- Opcodes ---- constexpr uint8_t kOpPresent = 0x01; @@ -162,6 +162,7 @@ constexpr uint8_t kOpResolveTargetId = 0x36; // {} resolve this browser's CDP targetId (CEF-2b) -> kOpTargetId constexpr uint8_t kOpInvalidate = 0x37; // {} C1: force a repaint (Invalidate PET_VIEW) to re-kick a stalled first frame constexpr uint8_t kOpEditCommand = 0x38; // {u8 cmd} run a focused-frame edit command (0=copy 1=cut 2=paste 3=selectAll 4=undo 5=redo) +constexpr uint8_t kOpOpenAuthWindow = 0x39; // {utf8 url} open a windowed Chrome-runtime browser for a WebAuthn/Touch ID ceremony the OSR tile can't host (shares the tile's cookie jar) // ---- Shared runtime state ---- // Atomic: the reader thread reads it (ReadAll), SendFrame on any thread reads it, @@ -1184,6 +1185,29 @@ void OnTitleChange(CefRefPtr, const CefString& title) override { IMPLEMENT_REFCOUNTING(PopupClient); }; +// SPIKE (passkeys): a bare client for a windowed, CHROME-runtime browser. The +// Chrome runtime manages its own native window, toolbar, and — crucially — the +// full WebAuthn stack (Touch ID sheet, account picker, hybrid QR), none of which +// exist in the Alloy/OSR runtime the tiles are forced into. So this needs no +// handlers; it exists to prove a Campus-spawned window can complete a passkey +// ceremony the OSR tile cannot. +class AuthWindowClient : public CefClient { + IMPLEMENT_REFCOUNTING(AuthWindowClient); +}; + +// SPIKE: open a real windowed Chrome-runtime browser at |url|, sharing THIS +// process's cookie jar (global request context == the tile's named profile, since +// profiles are per-cef_host-process via root_cache_path). Windowed + Chrome style +// = the WebAuthn UI can actually draw. Env-triggered one-shot from OnAfterCreated. +void OpenChromeAuthWindow(const std::string& url) { + CefWindowInfo wi; // default → windowed, CEF owns the NSWindow + wi.runtime_style = CEF_RUNTIME_STYLE_CHROME; // full Chrome UI + WebAuthn stack + wi.bounds = CefRect(120, 100, 520, 760); // a plausible sign-in window + CefBrowserSettings settings; + CefBrowserHost::CreateBrowser(wi, new AuthWindowClient(), url, settings, + nullptr, nullptr); // nullptr ctx = shared cookies +} + static void OpenNativeAuthPopup(const CefString& url, const CefPopupFeatures& f, CefWindowInfo& window_info, CefRefPtr& client) { @@ -1423,6 +1447,16 @@ void OnAfterCreated(CefRefPtr browser) override { slot_->begin_frame_pump_started = true; PumpBeginFrame(slot_->browser_id); } + // SPIKE (passkeys): one-shot — open a windowed Chrome-runtime auth window on + // the first tile, so we can test whether a passkey ceremony completes there + // (it hangs in the OSR/Alloy tile). Off unless FLUTTER_CEF_SPIKE_AUTH_URL set. + static std::atomic s_auth_opened{false}; + if (const char* au = std::getenv("FLUTTER_CEF_SPIKE_AUTH_URL")) { + bool expected = false; + if (s_auth_opened.compare_exchange_strong(expected, true)) { + OpenChromeAuthWindow(au); + } + } } // CefLifeSpanHandler: route popups (window.open / target=_blank) to the host @@ -2409,6 +2443,17 @@ void IpcReadLoop() { base::BindOnce(&DoNavigateByWireId, wire_id, url, true)); break; } + case kOpOpenAuthWindow: { + // Open a windowed Chrome-runtime browser at |url| for a WebAuthn / Touch + // ID ceremony. The OSR/Alloy tile CANNOT host WebAuthn (no window for the + // Touch ID sheet); the Chrome runtime can. nullptr request context = this + // process's global cookie jar == the tile's named profile (profiles are + // per-cef_host-process via root_cache_path), so a sign-in propagates back + // to the tile. Process-level: no slot required. + std::string url(reinterpret_cast(p), plen); + CefPostTask(TID_UI, base::BindOnce(&OpenChromeAuthWindow, url)); + break; + } case kOpReload: if (!slot) break; CefPostTask(TID_UI, base::BindOnce(&DoReload, slot)); diff --git a/packages/flutter_cef_macos/native/patches/campus_webauthn_keychain.patch b/packages/flutter_cef_macos/native/patches/campus_webauthn_keychain.patch new file mode 100644 index 0000000..1527bcd --- /dev/null +++ b/packages/flutter_cef_macos/native/patches/campus_webauthn_keychain.patch @@ -0,0 +1,13 @@ +diff --git a/chrome/browser/webauthn/chrome_web_authentication_delegate.cc b/chrome/browser/webauthn/chrome_web_authentication_delegate.cc +index 743cee559e61e..bba4356b78f26 100644 +--- a/chrome/browser/webauthn/chrome_web_authentication_delegate.cc ++++ b/chrome/browser/webauthn/chrome_web_authentication_delegate.cc +@@ -538,7 +538,7 @@ ChromeWebAuthenticationDelegate::TouchIdAuthenticatorConfig + ChromeWebAuthenticationDelegate::TouchIdAuthenticatorConfigForProfile( + Profile* profile) { + constexpr char kKeychainAccessGroup[] = +- MAC_TEAM_IDENTIFIER_STRING "." MAC_BUNDLE_IDENTIFIER_STRING ".webauthn"; ++ "KLAJ5X6PJP." MAC_BUNDLE_IDENTIFIER_STRING ".webauthn"; + + std::string metadata_secret = profile->GetPrefs()->GetString( + webauthn::pref_names::kWebAuthnTouchIdMetadataSecretPrefName); diff --git a/packages/flutter_cef_macos/tool/cef_host_hash.sh b/packages/flutter_cef_macos/tool/cef_host_hash.sh index 95db4ad..0a81f9c 100755 --- a/packages/flutter_cef_macos/tool/cef_host_hash.sh +++ b/packages/flutter_cef_macos/tool/cef_host_hash.sh @@ -23,6 +23,23 @@ _cefhost_sha256() { cef_host_input_hash() { local native_dir="$1" + # Campus CEF framework variant. 'stock' = the Spotify prebuilt CEF framework; + # anything else (e.g. 'campus-webauthn-h264') = a PATCHED from-source framework + # built by native/build-cef-from-source.sh (WebAuthn keychain group and/or + # proprietary codecs). The framework BYTES are NOT hashed here (only CEF_VERSION + # is, transitively via build_cef_host.sh) -- so WITHOUT this, a patched framework + # shipped under the same CEF_VERSION would collide with the stock content hash: + # publish-cef-host would idempotent-skip, cef-doctor would pass on the OLD stock + # object, and every consumer would silently fetch the UNPATCHED framework. Folding + # the variant into the digest gives a patched build a DISTINCT GCS key. Read from + # a version-controlled marker file so publish (CI) and fetch (pod install) agree. + # Emitted ONLY when non-stock, so the stock digest is byte-identical to before + # (no republish of the existing stock host required). + local variant="stock" + if [ -f "$native_dir/cef_host/CEF_FRAMEWORK_VARIANT" ]; then + variant="$(tr -d '[:space:]' < "$native_dir/cef_host/CEF_FRAMEWORK_VARIANT")" + [ -z "$variant" ] && variant="stock" + fi # 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. @@ -33,10 +50,30 @@ cef_host_input_hash() { printf '%s\n' "build_cef_host.sh" find cef_host -type f \ -not -path 'cef_host/prebuilt/*' \ - -not -path 'cef_host/build/*' + -not -path 'cef_host/build/*' \ + -not -path 'cef_host/CEF_FRAMEWORK_VARIANT' } | LC_ALL=C sort -u | while IFS= read -r f; do printf '%s\n' "$f" _cefhost_sha256 "$f" | awk '{print $1}' done + # Non-stock framework variant participates in the digest (see above). Appended + # AFTER the sorted file stream; stock => nothing emitted => identical digest. + if [ "$variant" != "stock" ]; then + printf 'CEF_FRAMEWORK_VARIANT\n%s\n' "$variant" + # The files that actually DEFINE a patched framework -- the patch set and the + # from-source build recipe -- live at native/ level (siblings of cef_host/), + # so the `find cef_host` stream above misses them. Fold their content in HERE, + # non-stock ONLY (so the stock digest stays byte-identical, no republish). + # Without this, editing a patch hunk or a gn-arg WITHOUT hand-bumping the + # variant string yields an identical hash -> publish idempotent-skips -> + # consumers silently fetch the STALE patched framework (WebAuthn code). + { + [ -f build-cef-from-source.sh ] && printf '%s\n' "build-cef-from-source.sh" + [ -d patches ] && find patches -type f + } | LC_ALL=C sort -u | while IFS= read -r pf; do + printf '%s\n' "$pf" + _cefhost_sha256 "$pf" | awk '{print $1}' + done + fi ) | _cefhost_sha256 | awk '{print $1}' }