9fd04b0ce4
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.
711 lines
26 KiB
Python
711 lines
26 KiB
Python
"""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),
|
|
}
|