feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
"""Deterministic Apple-signed Reyna CLI.app bundle builder via Xcode.
|
||||
|
||||
Bundle layout:
|
||||
<repo>/native/ReynaCLIHost/dist/Reyna CLI.app/
|
||||
Contents/
|
||||
Info.plist (bound, deterministic, CFBundleIdentifier=com.reyna.cli.privacy-host)
|
||||
MacOS/
|
||||
ReynaCLIHost (executable, 0755, copied atomically)
|
||||
|
||||
Security:
|
||||
- Xcode owns signing; no manual `codesign --sign` invocation.
|
||||
- Real build command is xcodebuild with Automatic Signing (CODE_SIGN_STYLE=Automatic in project).
|
||||
- For CI/test unsigned verification, caller may disable signing via CODE_SIGNING_ALLOWED=NO.
|
||||
- Validation remains fail-closed: non-adhoc signature, team present, bound Info plist, fixed bundle identifier.
|
||||
- All subprocess invocations use arg arrays, never shell.
|
||||
- No secret material logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import stat
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
BUNDLE_IDENTIFIER = "com.reyna.cli.privacy-host"
|
||||
APP_BUNDLE_NAME = "Reyna CLI.app"
|
||||
APP_EXECUTABLE_NAME = "ReynaCLIHost"
|
||||
BUNDLE_VERSION = "1"
|
||||
BUNDLE_SHORT_VERSION = "1.0.0"
|
||||
|
||||
XCODE_PROJECT_REL = Path("native") / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj"
|
||||
XCODE_TARGET_NAME = "Reyna CLI" # deprecated alias; use SCHEME for valid -derivedDataPath builds
|
||||
XCODE_SCHEME_NAME = "Reyna CLI"
|
||||
XCODE_CONFIGURATION = "Release"
|
||||
XCODE_INFO_PLIST_REL = Path("native") / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
DERIVED_DATA_REL = Path("native") / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
|
||||
SIGNING_IDENTITY_ENV_VAR = "REYNA_CLI_SIGNING_IDENTITY" # deprecated, kept for compat; no longer required
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _package_dir(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / "native" / "ReynaCLIHost"
|
||||
|
||||
|
||||
def _xcodeproj_path(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / XCODE_PROJECT_REL
|
||||
|
||||
|
||||
def _info_plist_source_path(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / XCODE_INFO_PLIST_REL
|
||||
|
||||
|
||||
def _derived_data_path(repo_root: Optional[Path] = None, override: Optional[Path] = None) -> Path:
|
||||
if override is not None:
|
||||
return Path(override)
|
||||
r = repo_root or _repo_root()
|
||||
return r / DERIVED_DATA_REL
|
||||
|
||||
|
||||
def _built_product_app_path(derived_data_path: Path) -> Path:
|
||||
return derived_data_path / "Build" / "Products" / XCODE_CONFIGURATION / APP_BUNDLE_NAME
|
||||
|
||||
|
||||
def app_bundle_dir(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / "native" / "ReynaCLIHost" / "dist"
|
||||
|
||||
|
||||
def app_bundle_path(repo_root: Optional[Path] = None) -> Path:
|
||||
return app_bundle_dir(repo_root) / APP_BUNDLE_NAME
|
||||
|
||||
|
||||
def app_bundle_info_plist_path(repo_root: Optional[Path] = None) -> Path:
|
||||
return app_bundle_path(repo_root) / "Contents" / "Info.plist"
|
||||
|
||||
|
||||
def app_bundle_executable_path(repo_root: Optional[Path] = None) -> Path:
|
||||
return app_bundle_path(repo_root) / "Contents" / "MacOS" / APP_EXECUTABLE_NAME
|
||||
|
||||
|
||||
def build_app_bundle_info_plist_dict() -> Dict[str, Any]:
|
||||
"""Deterministic Info.plist dict, sorted keys for reproducibility."""
|
||||
return {
|
||||
"CFBundleDevelopmentRegion": "en",
|
||||
"CFBundleDisplayName": "Reyna CLI",
|
||||
"CFBundleExecutable": APP_EXECUTABLE_NAME,
|
||||
"CFBundleIdentifier": BUNDLE_IDENTIFIER,
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleName": "Reyna CLI",
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleShortVersionString": BUNDLE_SHORT_VERSION,
|
||||
"CFBundleVersion": BUNDLE_VERSION,
|
||||
"LSMinimumSystemVersion": "13.0",
|
||||
"NSCalendarsFullAccessUsageDescription": "Reyna CLI needs calendar access to list and manage your events locally.",
|
||||
"NSContactsUsageDescription": "Reyna CLI needs contacts access to search and manage your contacts locally.",
|
||||
"NSRemindersFullAccessUsageDescription": "Reyna CLI needs reminders access to list and manage your reminders locally.",
|
||||
"LSUIElement": True,
|
||||
}
|
||||
|
||||
|
||||
def _default_runner(args: List[str], cwd: Optional[Path] = None, **kwargs: Any):
|
||||
return subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_dir_mode(path: Path, mode: int) -> None:
|
||||
try:
|
||||
st_l = path.lstat()
|
||||
if stat.S_ISLNK(st_l.st_mode):
|
||||
raise ValueError(f"refusing symlink directory: {path}")
|
||||
if not stat.S_ISDIR(st_l.st_mode):
|
||||
raise ValueError(f"path exists and is not a directory: {path}")
|
||||
os.chmod(path, mode)
|
||||
st = path.stat()
|
||||
if stat.S_IMODE(st.st_mode) != mode:
|
||||
raise PermissionError(f"directory mode {oct(stat.S_IMODE(st.st_mode))} != {oct(mode)}: {path}")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
os.chmod(path, mode)
|
||||
st_l = path.lstat()
|
||||
if stat.S_ISLNK(st_l.st_mode):
|
||||
raise ValueError(f"refusing symlink dir after creation: {path}")
|
||||
if not stat.S_ISDIR(st_l.st_mode):
|
||||
raise ValueError(f"path not dir after creation: {path}")
|
||||
st = path.stat()
|
||||
if stat.S_IMODE(st.st_mode) != mode:
|
||||
os.chmod(path, mode)
|
||||
st = path.stat()
|
||||
if stat.S_IMODE(st.st_mode) != mode:
|
||||
raise PermissionError(f"directory mode {oct(stat.S_IMODE(st.st_mode))} != {oct(mode)} after chmod: {path}")
|
||||
|
||||
|
||||
def _copy_app_bundle_atomic(src: Path, dst: Path) -> None:
|
||||
"""Atomically copy .app bundle from src to dst."""
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"source bundle not found: {src}")
|
||||
try:
|
||||
s_l = src.lstat()
|
||||
if stat.S_ISLNK(s_l.st_mode):
|
||||
raise ValueError(f"refusing symlink source bundle: {src}")
|
||||
if not stat.S_ISDIR(s_l.st_mode):
|
||||
raise ValueError(f"source bundle not a directory: {src}")
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
|
||||
parent = dst.parent
|
||||
_ensure_dir_mode(parent, 0o700)
|
||||
|
||||
tmp_name = f".{dst.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}"
|
||||
tmp_path = parent / tmp_name
|
||||
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
if tmp_path.is_dir():
|
||||
shutil.rmtree(str(tmp_path))
|
||||
else:
|
||||
tmp_path.unlink()
|
||||
|
||||
shutil.copytree(str(src), str(tmp_path), symlinks=False)
|
||||
|
||||
tst = tmp_path.lstat()
|
||||
if stat.S_ISLNK(tst.st_mode):
|
||||
raise ValueError(f"temp dst is symlink: {tmp_path}")
|
||||
if not stat.S_ISDIR(tst.st_mode):
|
||||
raise ValueError(f"temp dst not dir: {tmp_path}")
|
||||
|
||||
if dst.exists():
|
||||
dl = dst.lstat()
|
||||
if stat.S_ISLNK(dl.st_mode):
|
||||
raise ValueError(f"refusing to replace symlinked dst: {dst}")
|
||||
if dst.is_dir():
|
||||
shutil.rmtree(str(dst))
|
||||
else:
|
||||
dst.unlink()
|
||||
|
||||
os.replace(str(tmp_path), str(dst))
|
||||
|
||||
final_st = dst.lstat()
|
||||
if stat.S_ISLNK(final_st.st_mode):
|
||||
raise ValueError(f"final dst is symlink: {dst}")
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
if tmp_path.is_dir():
|
||||
shutil.rmtree(str(tmp_path))
|
||||
else:
|
||||
tmp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_xcodebuild_command(
|
||||
repo_root: Optional[Path] = None,
|
||||
derived_data_path: Optional[Path] = None,
|
||||
disable_code_signing: bool = False,
|
||||
) -> List[str]:
|
||||
"""Return safe arg-array xcodebuild command.
|
||||
|
||||
This is the canonical build command; Xcode owns signing (Automatic).
|
||||
Uses -scheme (shared, committed) with -derivedDataPath which requires scheme.
|
||||
When disable_code_signing is True, adds CODE_SIGNING_ALLOWED=NO for unsigned verification.
|
||||
"""
|
||||
r = repo_root or _repo_root()
|
||||
proj = _xcodeproj_path(r)
|
||||
dd = _derived_data_path(r, override=derived_data_path)
|
||||
|
||||
cmd: List[str] = [
|
||||
"xcodebuild",
|
||||
"-project",
|
||||
str(proj),
|
||||
"-scheme",
|
||||
XCODE_SCHEME_NAME,
|
||||
"-configuration",
|
||||
XCODE_CONFIGURATION,
|
||||
"-derivedDataPath",
|
||||
str(dd),
|
||||
"build",
|
||||
]
|
||||
if disable_code_signing:
|
||||
cmd.append("CODE_SIGNING_ALLOWED=NO")
|
||||
return cmd
|
||||
|
||||
|
||||
# Backward compat: old RELEASE_BUILD_ARGS now points to xcodebuild with default derived path
|
||||
# (callers should use build_xcodebuild_command for testability).
|
||||
def _default_xcodebuild_args_for_compat() -> List[str]:
|
||||
return build_xcodebuild_command()
|
||||
|
||||
|
||||
RELEASE_BUILD_ARGS: List[str] = _default_xcodebuild_args_for_compat()
|
||||
|
||||
|
||||
def _resolve_signing_identity(explicit: Optional[str]) -> Optional[str]:
|
||||
"""Deprecated: signing identity no longer required for Automatic Signing.
|
||||
Kept for backwards compatibility; returns identity if provided, else env var if set.
|
||||
"""
|
||||
if explicit is not None:
|
||||
s = str(explicit).strip()
|
||||
if s:
|
||||
return s
|
||||
return None
|
||||
env_val = os.environ.get(SIGNING_IDENTITY_ENV_VAR)
|
||||
if env_val is None:
|
||||
return None
|
||||
s = str(env_val).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _defense_check_source_app_path(p: Path) -> Optional[str]:
|
||||
"""Defend source .app path: absolute, .app suffix, dir, not symlink, no traversal tricks."""
|
||||
try:
|
||||
s = str(p)
|
||||
except Exception:
|
||||
return "invalid app bundle path"
|
||||
# Reject empty
|
||||
if not s:
|
||||
return "empty app bundle path"
|
||||
# Must be absolute
|
||||
if not p.is_absolute():
|
||||
return f"app bundle path must be absolute: {p}"
|
||||
# Must end with .app
|
||||
if not s.endswith(".app"):
|
||||
return f"app bundle path must end with .app suffix: {p}"
|
||||
# Reject if contains .. components to avoid traversal sneaks (even though absolute)
|
||||
# Use Path parts check: if \"..\" in parts
|
||||
if ".." in Path(s).parts:
|
||||
return f"app bundle path must not contain '..': {p}"
|
||||
try:
|
||||
st = p.lstat()
|
||||
except FileNotFoundError:
|
||||
return f"source bundle not found: {p}"
|
||||
except Exception as exc:
|
||||
return f"source bundle lstat failed: {exc}"
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
return f"refusing symlink source bundle: {p}"
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return f"source bundle is not a directory: {p}"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_bundle_at_paths(
|
||||
bundle_path: Path,
|
||||
exe_path: Path,
|
||||
plist_path: Path,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Core validation logic parameterized by explicit paths. No secret leakage."""
|
||||
run_fn = runner or _default_runner
|
||||
result: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"app_bundle_path": str(bundle_path),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"bundle_identifier_expected": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(exe_path),
|
||||
"info_plist_path": str(plist_path),
|
||||
"bundle_exists": False,
|
||||
"executable_exists": False,
|
||||
"info_plist_exists": False,
|
||||
"bundle_identifier_matches": False,
|
||||
"signature_verified": False,
|
||||
"is_ad_hoc": None,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
result["bundle_exists"] = bundle_path.exists()
|
||||
result["executable_exists"] = exe_path.exists()
|
||||
result["info_plist_exists"] = plist_path.exists()
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"existence check failed: {exc}")
|
||||
|
||||
if not result["bundle_exists"]:
|
||||
result["errors"].append(f"bundle missing at {bundle_path}")
|
||||
return result
|
||||
if not result["executable_exists"]:
|
||||
result["errors"].append(f"executable missing at {exe_path}")
|
||||
return result
|
||||
if not result["info_plist_exists"]:
|
||||
result["errors"].append(f"Info.plist missing at {plist_path}")
|
||||
return result
|
||||
|
||||
try:
|
||||
with open(plist_path, "rb") as f:
|
||||
d = plistlib.load(f)
|
||||
bid = d.get("CFBundleIdentifier")
|
||||
result["bundle_identifier_found"] = bid
|
||||
if bid == BUNDLE_IDENTIFIER:
|
||||
result["bundle_identifier_matches"] = True
|
||||
else:
|
||||
result["errors"].append(f"bundle identifier mismatch: expected {BUNDLE_IDENTIFIER} got {bid}")
|
||||
return result
|
||||
|
||||
bexe = d.get("CFBundleExecutable")
|
||||
if bexe != APP_EXECUTABLE_NAME:
|
||||
result["errors"].append(f"CFBundleExecutable mismatch: expected {APP_EXECUTABLE_NAME} got {bexe}")
|
||||
return result
|
||||
|
||||
if "NSCalendarsFullAccessUsageDescription" not in d:
|
||||
result["errors"].append("missing NSCalendarsFullAccessUsageDescription")
|
||||
return result
|
||||
|
||||
if "NSContactsUsageDescription" not in d:
|
||||
result["errors"].append("missing NSContactsUsageDescription")
|
||||
return result
|
||||
|
||||
if "NSRemindersFullAccessUsageDescription" not in d:
|
||||
result["errors"].append("missing NSRemindersFullAccessUsageDescription")
|
||||
return result
|
||||
|
||||
# Notes (AppleEvents) deliberately deferred — must NOT be required
|
||||
# Forbid AppleEvents / Notes usage description
|
||||
if "NSAppleEventsUsageDescription" in d:
|
||||
result["errors"].append("forbidden usage description present: NSAppleEventsUsageDescription (Notes deferred)")
|
||||
return result
|
||||
|
||||
forbidden_keys = [
|
||||
"NSRemindersUsageDescription",
|
||||
]
|
||||
for fk in forbidden_keys:
|
||||
if fk in d:
|
||||
result["errors"].append(f"forbidden usage description present: {fk}")
|
||||
return result
|
||||
|
||||
allowed_usage_keys = {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}
|
||||
for k in d.keys():
|
||||
if k.startswith("NS") and "UsageDescription" in k:
|
||||
if k not in allowed_usage_keys:
|
||||
result["errors"].append(f"unexpected usage description key: {k}")
|
||||
return result
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"Info.plist read/validation failed: {exc}")
|
||||
return result
|
||||
|
||||
try:
|
||||
proc = run_fn(["codesign", "--verify", "--deep", "--strict", str(bundle_path)])
|
||||
rc = getattr(proc, "returncode", -1)
|
||||
if rc == 0:
|
||||
result["signature_verified"] = True
|
||||
else:
|
||||
result["signature_verified"] = False
|
||||
# Do not leak raw codesign identity output; truncate generic message
|
||||
result["errors"].append(f"codesign verify failed rc={rc}")
|
||||
return result
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"codesign verify exception: {exc}")
|
||||
return result
|
||||
|
||||
try:
|
||||
proc2 = run_fn(["codesign", "-dv", str(bundle_path)])
|
||||
stderr = (getattr(proc2, "stderr", "") or "") + (getattr(proc2, "stdout", "") or "")
|
||||
lower = stderr.lower()
|
||||
is_ad_hoc = False
|
||||
if "signature=adhoc" in lower:
|
||||
is_ad_hoc = True
|
||||
if "teamidentifier=not set" in lower:
|
||||
is_ad_hoc = True
|
||||
result["is_ad_hoc"] = is_ad_hoc
|
||||
if is_ad_hoc:
|
||||
result["errors"].append("bundle is ad-hoc signed (TeamIdentifier not set) - stable TCC identity required")
|
||||
result["signature_verified"] = False
|
||||
return result
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"ad-hoc check failed: {exc}")
|
||||
result["signature_verified"] = False
|
||||
return result
|
||||
|
||||
result["ok"] = True
|
||||
return result
|
||||
|
||||
|
||||
def validate_app_bundle(
|
||||
repo_root: Optional[Path] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate bundle layout and signature without logging secrets (dist location)."""
|
||||
r = repo_root or _repo_root()
|
||||
bundle = app_bundle_path(r)
|
||||
exe = app_bundle_executable_path(r)
|
||||
plist_p = app_bundle_info_plist_path(r)
|
||||
return _validate_bundle_at_paths(bundle, exe, plist_p, runner=runner)
|
||||
|
||||
|
||||
def validate_app_bundle_at_path(
|
||||
bundle_path: Path,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate an arbitrary source .app bundle path (absolute, defended) using full signed validation."""
|
||||
bp = Path(bundle_path)
|
||||
# Basic defense (no copy, just validation): must be absolute .app dir, not symlink
|
||||
err = _defense_check_source_app_path(bp)
|
||||
if err:
|
||||
return {
|
||||
"ok": False,
|
||||
"app_bundle_path": str(bp),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"bundle_identifier_expected": BUNDLE_IDENTIFIER,
|
||||
"bundle_exists": False,
|
||||
"signature_verified": False,
|
||||
"is_ad_hoc": None,
|
||||
"errors": [err],
|
||||
}
|
||||
exe = bp / "Contents" / "MacOS" / APP_EXECUTABLE_NAME
|
||||
plist_p = bp / "Contents" / "Info.plist"
|
||||
return _validate_bundle_at_paths(bp, exe, plist_p, runner=runner)
|
||||
|
||||
|
||||
def install_prebuilt_app_bundle(
|
||||
source_bundle_path: Path,
|
||||
repo_root: Optional[Path] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Secure prebuilt install: validate source, atomic copy to dist, validate copy. No xcodebuild."""
|
||||
run_fn = runner or _default_runner
|
||||
r = repo_root or _repo_root()
|
||||
src = Path(source_bundle_path)
|
||||
|
||||
def_err = _defense_check_source_app_path(src)
|
||||
if def_err:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": def_err,
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(app_bundle_path(r)),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
# Validate source before any copy
|
||||
src_validation = validate_app_bundle_at_path(src, runner=run_fn)
|
||||
if not src_validation.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": f"source bundle validation failed: {'; '.join(src_validation.get('errors', []))}",
|
||||
"validation": src_validation,
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(app_bundle_path(r)),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
dist_bundle = app_bundle_path(r)
|
||||
try:
|
||||
dist_dir = app_bundle_dir(r)
|
||||
_ensure_dir_mode(dist_dir, 0o700)
|
||||
_copy_app_bundle_atomic(src, dist_bundle)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": f"bundle copy failed: {exc}",
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
# Validate copy
|
||||
dst_validation = validate_app_bundle(repo_root=r, runner=run_fn)
|
||||
if not dst_validation.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": f"copied bundle validation failed: {'; '.join(dst_validation.get('errors', []))}",
|
||||
"validation": dst_validation,
|
||||
"source_validation": src_validation,
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(app_bundle_executable_path(r)),
|
||||
"info_plist_path": str(app_bundle_info_plist_path(r)),
|
||||
"signature_verified": True,
|
||||
"validation": {k: v for k, v in dst_validation.items() if k != "errors" or v},
|
||||
"source_validation": {k: v for k, v in src_validation.items() if k != "errors" or v},
|
||||
"source_bundle_path": str(src),
|
||||
}
|
||||
|
||||
|
||||
def build_app_bundle(
|
||||
signing_identity: Optional[str] = None,
|
||||
repo_root: Optional[Path] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
disable_code_signing: bool = False,
|
||||
derived_data_path_override: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build deterministic Reyna CLI.app bundle via xcodebuild.
|
||||
|
||||
Steps:
|
||||
1. Compute xcodeproject path and controlled DerivedData path.
|
||||
2. Run xcodebuild -project <project> -scheme 'Reyna CLI' -configuration Release -derivedDataPath <controlled> build
|
||||
(with CODE_SIGNING_ALLOWED=NO when disable_code_signing=True for unsigned verification).
|
||||
Xcode owns signing via Automatic Signing; no manual `codesign --sign`.
|
||||
3. Locate product app in DerivedData/Build/Products/Release/Reyna CLI.app
|
||||
4. Safely copy it to native/ReynaCLIHost/dist/Reyna CLI.app
|
||||
5. Validate final bundle (identifier, not ad-hoc when signed).
|
||||
|
||||
signing_identity arg is deprecated and ignored for Automatic Signing; kept for compat.
|
||||
"""
|
||||
run_fn = runner or _default_runner
|
||||
r = repo_root or _repo_root()
|
||||
proj_path = _xcodeproj_path(r)
|
||||
derived_path = _derived_data_path(r, override=derived_data_path_override)
|
||||
built_product = _built_product_app_path(derived_path)
|
||||
dist_bundle = app_bundle_path(r)
|
||||
exe_dst = app_bundle_executable_path(r)
|
||||
plist_dst = app_bundle_info_plist_path(r)
|
||||
|
||||
if not proj_path.exists():
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"xcodeproj not found at {proj_path}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
info_src = _info_plist_source_path(r)
|
||||
if not info_src.exists():
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"Info.plist source not found at {info_src}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
build_cmd = build_xcodebuild_command(
|
||||
repo_root=r,
|
||||
derived_data_path=derived_path,
|
||||
disable_code_signing=disable_code_signing,
|
||||
)
|
||||
|
||||
try:
|
||||
proc = run_fn(build_cmd)
|
||||
rc = getattr(proc, "returncode", 0)
|
||||
out = getattr(proc, "stdout", "") or ""
|
||||
err = getattr(proc, "stderr", "") or ""
|
||||
if rc != 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"xcodebuild failed rc={rc}",
|
||||
"stdout": out[-2000:],
|
||||
"stderr": err[-2000:],
|
||||
"build_command": build_cmd,
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"derived_data_path": str(derived_path),
|
||||
"xcodeproj_path": str(proj_path),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"xcodebuild exception: {exc}",
|
||||
"build_command": build_cmd,
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"derived_data_path": str(derived_path),
|
||||
"xcodeproj_path": str(proj_path),
|
||||
}
|
||||
|
||||
if not built_product.exists():
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"built product not found after build at {built_product}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
|
||||
try:
|
||||
dist_dir = app_bundle_dir(r)
|
||||
_ensure_dir_mode(dist_dir, 0o700)
|
||||
_copy_app_bundle_atomic(built_product, dist_bundle)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"bundle copy failed: {exc}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
|
||||
# Final validation (optional but informative; unsigned builds will fail validation)
|
||||
validation = validate_app_bundle(repo_root=r, runner=run_fn)
|
||||
|
||||
# If signing was disabled, we don't require validation ok, but report state
|
||||
if disable_code_signing:
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "build_app_bundle",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(exe_dst),
|
||||
"info_plist_path": str(plist_dst),
|
||||
"signature_verified": validation.get("signature_verified", False),
|
||||
"validation": validation,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
"unsigned_build": True,
|
||||
}
|
||||
|
||||
if not validation.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"bundle validation failed: {'; '.join(validation.get('errors', []))}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"validation": validation,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "build_app_bundle",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(exe_dst),
|
||||
"info_plist_path": str(plist_dst),
|
||||
"signature_verified": True,
|
||||
"validation": {k: v for k, v in validation.items() if k != "errors" or v},
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
Reference in New Issue
Block a user