#!/usr/bin/env python3
"""Validate the App Group / Keychain wiring that the app hard-depends on.
Why this is a CI gate and not a unit test: `AppGroup.defaults`,
`AppGroupStore`, `FlowSessionBridge` and `FlowSessionPolicy` all call
`fatalError` when the App Group container is missing, by design — a silent
`.standard` fallback would desync the keyboard extension from the host app and
produce "I set my API key and nothing happens". The cost of that design is that
a single typo or a dropped entitlement turns into a keyboard that crashes on
every presentation for every user. Nothing else catches it: the failure needs a
signed build on a real device.
Checks (all static, no Xcode required):
1. `AppGroup.identifier` in Swift matches `com.apple.security.application-groups`
in every entitlements file that ships an App Group, and in `project.yml`.
2. Both the iOS app and the keyboard extension declare the App Group — they
are the two processes that must share one suite.
3. `keychain-access-groups` lists `$(AppIdentifierPrefix)com.osgkeyboard.shared`
FIRST in every target. `Keychain.swift` deliberately relies on the default
access group (the first entry) instead of passing `kSecAttrAccessGroup`,
so a reorder silently moves every stored API key to a different store.
4. `project.yml` and the checked-in `.entitlements` files agree. XcodeGen
regenerates the latter from the former, so a drifted `.entitlements` means
someone hand-edited the generated output.
Run: python3 Scripts/check_app_group_wiring.py
python3 Scripts/check_app_group_wiring.py --self-test
"""
from __future__ import annotations
import plistlib
import re
import shutil
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
APP_GROUP_SWIFT = REPO / "OSGKeyboardShared/Constants/AppGroup.swift"
PROJECT_YML = REPO / "project.yml"
# target label -> (entitlements path, must declare an App Group)
TARGETS = {
"OSGKeyboard (iOS app)": ("OSGKeyboard/OSGKeyboard.entitlements", True),
"OSGKeyboardExt (keyboard)": ("OSGKeyboardExt/OSGKeyboardExt.entitlements", True),
"OSGKeyboardMac (menu bar)": ("OSGKeyboardMac/OSGKeyboardMac.entitlements", False),
}
APP_GROUP_KEY = "com.apple.security.application-groups"
KEYCHAIN_KEY = "keychain-access-groups"
EXPECTED_DEFAULT_KEYCHAIN_GROUP = "$(AppIdentifierPrefix)com.osgkeyboard.shared"
errors: list[str] = []
def fail(message: str) -> None:
errors.append(message)
def swift_app_group_identifier() -> str | None:
text = APP_GROUP_SWIFT.read_text(encoding="utf-8")
match = re.search(
r'public\s+static\s+let\s+identifier\s*=\s*"([^"]+)"', text
)
if not match:
fail(f"{APP_GROUP_SWIFT.relative_to(REPO)}: could not find `AppGroup.identifier`")
return None
return match.group(1)
def load_entitlements(path: Path) -> dict | None:
if not path.exists():
fail(f"{path.relative_to(REPO)}: entitlements file is missing")
return None
try:
with path.open("rb") as handle:
return plistlib.load(handle)
except Exception as error: # noqa: BLE001 - surfaced verbatim to the developer
fail(f"{path.relative_to(REPO)}: not a readable plist ({error})")
return None
def project_yml_blocks() -> dict[str, str]:
"""Split `project.yml` into one text block per `entitlements:` stanza."""
text = PROJECT_YML.read_text(encoding="utf-8")
blocks: dict[str, str] = {}
for match in re.finditer(
r"^ entitlements:\n path:\s*(\S+)\n(.*?)(?=^ \w|\Z)",
text,
re.MULTILINE | re.DOTALL,
):
blocks[match.group(1)] = match.group(2)
return blocks
def yaml_list_after(block: str, key: str) -> list[str] | None:
"""Reads a simple `key:` / `- value` list out of one entitlements stanza."""
pattern = re.compile(rf"^\s*{re.escape(key)}:\s*\n((?:\s*(?:#.*)?\n|\s*-\s*\S.*\n)+)", re.MULTILINE)
match = pattern.search(block)
if not match:
return None
values = []
for line in match.group(1).splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not stripped.startswith("- "):
break
values.append(stripped[2:].strip())
return values
def main() -> int:
identifier = swift_app_group_identifier()
if identifier is None:
print("\n".join(f"✗ {error}" for error in errors))
return 1
print(f"AppGroup.identifier = {identifier}")
yml_blocks = project_yml_blocks()
for label, (relative_path, requires_app_group) in TARGETS.items():
before = len(errors)
path = REPO / relative_path
plist = load_entitlements(path)
if plist is None:
continue
groups = plist.get(APP_GROUP_KEY)
if requires_app_group:
if not groups:
fail(f"{label}: entitlements declare no `{APP_GROUP_KEY}`")
elif identifier not in groups:
fail(
f"{label}: entitlements declare {groups!r}, "
f"which does not contain AppGroup.identifier ({identifier!r})"
)
keychain = plist.get(KEYCHAIN_KEY)
if not keychain:
fail(f"{label}: entitlements declare no `{KEYCHAIN_KEY}`")
elif keychain[0] != EXPECTED_DEFAULT_KEYCHAIN_GROUP:
fail(
f"{label}: `{KEYCHAIN_KEY}` starts with {keychain[0]!r}; "
f"Keychain.swift relies on {EXPECTED_DEFAULT_KEYCHAIN_GROUP!r} "
"being the FIRST (default) entry"
)
block = yml_blocks.get(relative_path)
if block is None:
fail(f"{label}: project.yml has no `entitlements: path: {relative_path}` stanza")
print(f" ✗ {label}")
continue
yml_groups = yaml_list_after(block, APP_GROUP_KEY)
if requires_app_group and (yml_groups or []) != (groups or []):
fail(
f"{label}: project.yml `{APP_GROUP_KEY}` ({yml_groups!r}) "
f"disagrees with the generated entitlements ({groups!r})"
)
yml_keychain = yaml_list_after(block, KEYCHAIN_KEY)
if (yml_keychain or []) != (keychain or []):
fail(
f"{label}: project.yml `{KEYCHAIN_KEY}` ({yml_keychain!r}) "
f"disagrees with the generated entitlements ({keychain!r})"
)
if len(errors) == before:
print(f" ✓ {label}")
else:
print(f" ✗ {label}")
if errors:
print()
for error in errors:
print(f"✗ {error}")
print(
"\nThe App Group is a hard dependency: the keyboard extension calls "
"fatalError() when its container is missing, so a mismatch here ships "
"a keyboard that crashes on every presentation."
)
return 1
print("\nApp Group and Keychain wiring OK.")
return 0
def self_test() -> int:
"""Proves the guard fails on the two regressions it exists to catch.
A checker that cannot fail is worse than no checker — it reads green
forever while the thing it guards rots.
"""
global REPO, APP_GROUP_SWIFT, PROJECT_YML, errors
original_repo = REPO
cases = [
(
"App Group typo in the keyboard extension",
"OSGKeyboardExt/OSGKeyboardExt.entitlements",
lambda text: text.replace(
"group.com.osgkeyboard.shared", "group.com.osgkeyboard.shard"
),
),
(
"keychain-access-groups reordered in the iOS app",
"OSGKeyboard/OSGKeyboard.entitlements",
lambda text: text.replace(
"$(AppIdentifierPrefix)com.osgkeyboard.shared\n"
"\t\t$(AppIdentifierPrefix)com.osgkeyboard.ios",
"$(AppIdentifierPrefix)com.osgkeyboard.ios\n"
"\t\t$(AppIdentifierPrefix)com.osgkeyboard.shared",
),
),
]
failures = 0
for label, relative_path, mutate in cases:
with tempfile.TemporaryDirectory() as temporary:
sandbox = Path(temporary) / "repo"
shutil.copytree(
original_repo,
sandbox,
symlinks=True,
ignore=shutil.ignore_patterns(
".git", "build", "DerivedData", "ThirdParty", ".claude"
),
)
target = sandbox / relative_path
mutated = mutate(target.read_text(encoding="utf-8"))
if mutated == target.read_text(encoding="utf-8"):
print(f"✗ self-test could not apply the mutation for: {label}")
failures += 1
continue
target.write_text(mutated, encoding="utf-8")
REPO = sandbox
APP_GROUP_SWIFT = sandbox / "OSGKeyboardShared/Constants/AppGroup.swift"
PROJECT_YML = sandbox / "project.yml"
errors = []
code = main()
REPO = original_repo
APP_GROUP_SWIFT = original_repo / "OSGKeyboardShared/Constants/AppGroup.swift"
PROJECT_YML = original_repo / "project.yml"
errors = []
if code == 0:
print(f"✗ self-test: guard did NOT catch: {label}")
failures += 1
else:
print(f"✓ self-test: guard catches: {label}")
return 1 if failures else 0
if __name__ == "__main__":
if "--self-test" in sys.argv[1:]:
sys.exit(self_test())
sys.exit(main())