chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: codexbar
|
||||
description: "CodexBar read. Provider usage, limits, credits, config health. JSON. No writes."
|
||||
---
|
||||
|
||||
# CodexBar
|
||||
|
||||
Read CodexBar. Never mutate config/auth.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
skill="${CODEX_HOME:-$HOME/.codex}/skills/codexbar"
|
||||
"$skill/scripts/codexbar" doctor
|
||||
"$skill/scripts/codexbar" providers
|
||||
"$skill/scripts/codexbar" usage
|
||||
"$skill/scripts/codexbar" usage --provider codex
|
||||
"$skill/scripts/codexbar" usage --all
|
||||
```
|
||||
|
||||
All stdout: JSON. Upstream CodexBar shape kept. Less drift, fewer tokens.
|
||||
|
||||
## Rules
|
||||
|
||||
- Start `doctor` when install/config unknown.
|
||||
- `usage` reads enabled providers. Prefer this.
|
||||
- `usage --provider ID` reads one provider.
|
||||
- `usage --all` expensive; use only when needed.
|
||||
- Identities hidden by default. `--include-identities` only when user explicitly needs them.
|
||||
- Secrets always hidden.
|
||||
- Helper read-only: fixed allowlist only. No config writes, auth repair, enable/disable, key storage.
|
||||
- Timeout means upstream stuck. Narrow provider or raise `CODEXBAR_TIMEOUT` (default 120 seconds).
|
||||
|
||||
## Binary
|
||||
|
||||
Auto-find: `CODEXBAR_BIN`, PATH, app bundle, Homebrew cask. If missing: open CodexBar, Preferences > Advanced > Install CLI; or set `CODEXBAR_BIN`.
|
||||
|
||||
Each stdout/stderr stream capped at 1 MiB while fully drained. Timeout kills process group.
|
||||
Executable
+321
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only, bounded CodexBar CLI bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
DEFAULT_TIMEOUT = 120.0
|
||||
BIN_ENV = "CODEXBAR_BIN"
|
||||
TIMEOUT_ENV = "CODEXBAR_TIMEOUT"
|
||||
SKIP_DISCOVERY_ENV = "CODEXBAR_SKIP_DISCOVERY"
|
||||
|
||||
SECRET = "<redacted:secret>"
|
||||
IDENTITY = "<redacted:identity>"
|
||||
EMAIL = "<redacted:email>"
|
||||
|
||||
EMAIL_RE = re.compile(r"\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b", re.I)
|
||||
BEARER_RE = re.compile(r"(?i)\bbearer\s+[A-Z0-9._~+/=\-]+")
|
||||
JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b")
|
||||
LABELED_SECRET_RE = re.compile(
|
||||
r"(?i)\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
|
||||
r"session(?:id)?|secret|password|passwd|cookie|set-cookie)(\s*[:=]\s*)([^\s,;]+)"
|
||||
)
|
||||
WORD_SECRET_RE = re.compile(
|
||||
r"(?i)\b(token|secret|password|passwd|cookie)\s+([A-Z0-9._~+/=\-]{6,})\b"
|
||||
)
|
||||
SECRET_KEY_RE = re.compile(
|
||||
r"(?i)(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
|
||||
r"session(?:id)?|secret|password|passwd|cookie)"
|
||||
)
|
||||
IDENTITY_KEYS = {
|
||||
"accountemail",
|
||||
"accountorganization",
|
||||
"accountid",
|
||||
"accountname",
|
||||
"email",
|
||||
"organization",
|
||||
"userid",
|
||||
"username",
|
||||
}
|
||||
# ProviderIdentitySnapshot.providerID is UsageProvider, not an account identifier.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binary:
|
||||
path: str
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
returncode: int
|
||||
stdout: bytes
|
||||
stderr: bytes
|
||||
timed_out: bool
|
||||
|
||||
|
||||
def normalized_key(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", value.lower())
|
||||
|
||||
|
||||
def timeout_seconds() -> float:
|
||||
try:
|
||||
return max(1.0, float(os.environ.get(TIMEOUT_ENV, DEFAULT_TIMEOUT)))
|
||||
except ValueError:
|
||||
return DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
def safe_path(path: str) -> str:
|
||||
home = str(Path.home())
|
||||
if path == home:
|
||||
return "~"
|
||||
if path.startswith(home + os.sep):
|
||||
return "~" + path[len(home) :]
|
||||
return re.sub(r"^/Users/[^/]+", "/Users/<redacted>", path)
|
||||
|
||||
|
||||
def candidates() -> list[tuple[str, Path]]:
|
||||
found: list[tuple[str, Path]] = []
|
||||
override = os.environ.get(BIN_ENV)
|
||||
if override:
|
||||
found.append(("env", Path(override).expanduser()))
|
||||
|
||||
path_hit = shutil.which("codexbar")
|
||||
if path_hit:
|
||||
found.append(("path", Path(path_hit)))
|
||||
|
||||
if os.environ.get(SKIP_DISCOVERY_ENV) == "1":
|
||||
return found
|
||||
|
||||
home = Path.home()
|
||||
found.extend(
|
||||
[
|
||||
("homebrew", Path("/opt/homebrew/bin/codexbar")),
|
||||
("homebrew", Path("/usr/local/bin/codexbar")),
|
||||
("app", Path("/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI")),
|
||||
("app", home / "Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"),
|
||||
]
|
||||
)
|
||||
for root in (Path("/opt/homebrew/Caskroom/codexbar"), Path("/usr/local/Caskroom/codexbar")):
|
||||
found.extend(("cask", app / "Contents/Helpers/CodexBarCLI") for app in sorted(root.glob("*/CodexBar.app")))
|
||||
return found
|
||||
|
||||
|
||||
def resolve_binary() -> Binary | None:
|
||||
self_path = Path(__file__).resolve()
|
||||
seen: set[Path] = set()
|
||||
for source, candidate in candidates():
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
if resolved in seen or resolved == self_path:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
if resolved.is_file() and os.access(resolved, os.X_OK):
|
||||
return Binary(str(resolved), source)
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def drain(pipe: Any, target: bytearray) -> None:
|
||||
try:
|
||||
while True:
|
||||
chunk = pipe.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
room = MAX_CAPTURE_BYTES - len(target)
|
||||
if room > 0:
|
||||
target.extend(chunk[:room])
|
||||
finally:
|
||||
pipe.close()
|
||||
|
||||
|
||||
def stop_group(process: subprocess.Popen[bytes], sig: signal.Signals) -> None:
|
||||
try:
|
||||
os.killpg(process.pid, sig)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def run_process(argv: Sequence[str]) -> Result:
|
||||
process = subprocess.Popen(
|
||||
list(argv),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
stdout = bytearray()
|
||||
stderr = bytearray()
|
||||
readers = [
|
||||
threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True),
|
||||
threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True),
|
||||
]
|
||||
for reader in readers:
|
||||
reader.start()
|
||||
|
||||
timed_out = False
|
||||
try:
|
||||
process.wait(timeout=timeout_seconds())
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
stop_group(process, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
stop_group(process, signal.SIGKILL)
|
||||
process.wait()
|
||||
|
||||
for reader in readers:
|
||||
reader.join(timeout=3)
|
||||
return Result(124 if timed_out else process.returncode, bytes(stdout), bytes(stderr), timed_out)
|
||||
|
||||
|
||||
def decode(value: bytes) -> str:
|
||||
return value.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def sanitize_text(value: str, include_identities: bool) -> str:
|
||||
value = JWT_RE.sub(SECRET, value)
|
||||
value = BEARER_RE.sub("Bearer " + SECRET, value)
|
||||
value = LABELED_SECRET_RE.sub(lambda match: match.group(1) + match.group(2) + SECRET, value)
|
||||
value = WORD_SECRET_RE.sub(lambda match: match.group(1) + " " + SECRET, value)
|
||||
if not include_identities:
|
||||
value = EMAIL_RE.sub(EMAIL, value)
|
||||
return value
|
||||
|
||||
|
||||
def sanitize(value: Any, include_identities: bool, key: str | None = None) -> Any:
|
||||
if key and SECRET_KEY_RE.search(key):
|
||||
return SECRET
|
||||
if key and normalized_key(key) in IDENTITY_KEYS and not include_identities and value is not None:
|
||||
return EMAIL if "email" in normalized_key(key) else IDENTITY
|
||||
if isinstance(value, dict):
|
||||
return {str(child_key): sanitize(child, include_identities, str(child_key)) for child_key, child in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [sanitize(child, include_identities) for child in value]
|
||||
if isinstance(value, str):
|
||||
return sanitize_text(value, include_identities)
|
||||
return value
|
||||
|
||||
|
||||
def print_json(value: Any) -> None:
|
||||
print(json.dumps(value, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def print_error(kind: str, message: str, *, binary: Binary | None = None) -> None:
|
||||
payload: dict[str, Any] = {"error": {"kind": kind, "message": sanitize_text(message, False)}}
|
||||
if binary:
|
||||
payload["binary"] = {"path": safe_path(binary.path), "source": binary.source}
|
||||
print_json(payload)
|
||||
|
||||
|
||||
def emit_stderr(result: Result, include_identities: bool) -> None:
|
||||
text = sanitize_text(decode(result.stderr), include_identities).strip()
|
||||
if text:
|
||||
print(text, file=sys.stderr)
|
||||
|
||||
|
||||
def read_json(binary: Binary, argv: Sequence[str], include_identities: bool) -> int:
|
||||
result = run_process([binary.path, *argv])
|
||||
emit_stderr(result, include_identities)
|
||||
if result.timed_out:
|
||||
print_error("timeout", f"CodexBar exceeded {timeout_seconds():g}s and was stopped.", binary=binary)
|
||||
return 124
|
||||
try:
|
||||
payload = json.loads(decode(result.stdout))
|
||||
except json.JSONDecodeError as error:
|
||||
preview = sanitize_text(decode(result.stdout[:400]), False)
|
||||
print_error("invalid_json", f"CodexBar JSON failed: {error.msg}. stdout={preview!r}", binary=binary)
|
||||
return result.returncode or 1
|
||||
print_json(sanitize(payload, include_identities))
|
||||
return result.returncode
|
||||
|
||||
|
||||
def doctor(binary: Binary, include_identities: bool) -> int:
|
||||
version = run_process([binary.path, "--version"])
|
||||
if version.timed_out:
|
||||
print_error("timeout", f"CodexBar version exceeded {timeout_seconds():g}s.", binary=binary)
|
||||
return 124
|
||||
validation = run_process([binary.path, "config", "validate", "--format", "json", "--json-only"])
|
||||
emit_stderr(version, include_identities)
|
||||
emit_stderr(validation, include_identities)
|
||||
if validation.timed_out:
|
||||
print_error("timeout", f"CodexBar config validation exceeded {timeout_seconds():g}s.", binary=binary)
|
||||
return 124
|
||||
try:
|
||||
issues = json.loads(decode(validation.stdout))
|
||||
except json.JSONDecodeError as error:
|
||||
print_error("invalid_json", f"CodexBar config validation failed: {error.msg}.", binary=binary)
|
||||
return validation.returncode or 1
|
||||
print_json(
|
||||
{
|
||||
"binary": {"path": safe_path(binary.path), "source": binary.source},
|
||||
"configIssues": sanitize(issues, include_identities),
|
||||
"version": sanitize_text((decode(version.stdout) or decode(version.stderr)).strip(), include_identities),
|
||||
}
|
||||
)
|
||||
return version.returncode or validation.returncode
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
root = argparse.ArgumentParser(prog="codexbar", description="CodexBar read. JSON out. No writes.")
|
||||
commands = root.add_subparsers(dest="command", required=True)
|
||||
for name in ("doctor", "providers"):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument("--include-identities", action="store_true")
|
||||
usage = commands.add_parser("usage")
|
||||
scope = usage.add_mutually_exclusive_group()
|
||||
scope.add_argument("--all", action="store_true")
|
||||
scope.add_argument("--provider")
|
||||
usage.add_argument("--include-identities", action="store_true")
|
||||
return root
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parser().parse_args(argv)
|
||||
binary = resolve_binary()
|
||||
if not binary:
|
||||
print_error("missing", "CodexBar CLI not found. Install CLI in CodexBar Advanced settings or set CODEXBAR_BIN.")
|
||||
return 1
|
||||
try:
|
||||
if args.command == "doctor":
|
||||
return doctor(binary, args.include_identities)
|
||||
if args.command == "providers":
|
||||
return read_json(
|
||||
binary,
|
||||
["config", "providers", "--format", "json", "--json-only"],
|
||||
args.include_identities,
|
||||
)
|
||||
usage_args = ["usage", "--format", "json", "--json-only"]
|
||||
if args.all:
|
||||
usage_args.extend(["--provider", "all"])
|
||||
elif args.provider:
|
||||
usage_args.extend(["--provider", args.provider])
|
||||
return read_json(binary, usage_args, args.include_identities)
|
||||
except OSError as error:
|
||||
print_error("launch", str(error), binary=binary)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
print_error("interrupted", "Interrupted.")
|
||||
raise SystemExit(130)
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("codexbar")
|
||||
MODULE = runpy.run_path(str(SCRIPT), run_name="codexbar_skill")
|
||||
MAX_CAPTURE_BYTES = MODULE["MAX_CAPTURE_BYTES"]
|
||||
run_process = MODULE["run_process"]
|
||||
|
||||
FAKE = textwrap.dedent(
|
||||
"""\
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
argv = sys.argv[1:]
|
||||
log = os.environ.get("FAKE_LOG")
|
||||
if log:
|
||||
with open(log, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(argv) + "\\n")
|
||||
|
||||
if argv == ["--version"]:
|
||||
key = "VERSION"
|
||||
elif argv[:2] == ["config", "validate"]:
|
||||
key = "VALIDATE"
|
||||
elif argv[:2] == ["config", "providers"]:
|
||||
key = "PROVIDERS"
|
||||
elif argv and argv[0] == "usage":
|
||||
key = "USAGE"
|
||||
else:
|
||||
key = "OTHER"
|
||||
|
||||
if os.environ.get("FAKE_SPAWN_CHILD") == "1":
|
||||
marker = os.environ["FAKE_MARKER"]
|
||||
subprocess.Popen([
|
||||
sys.executable,
|
||||
"-c",
|
||||
f"import pathlib,time; time.sleep(2); pathlib.Path({marker!r}).write_text('alive')",
|
||||
])
|
||||
time.sleep(5)
|
||||
|
||||
delay = os.environ.get(f"FAKE_{key}_DELAY")
|
||||
if delay:
|
||||
time.sleep(float(delay))
|
||||
stdout = os.environ.get(f"FAKE_{key}_STDOUT", "")
|
||||
stderr = os.environ.get(f"FAKE_{key}_STDERR", "")
|
||||
sys.stdout.write(stdout)
|
||||
sys.stderr.write(stderr)
|
||||
raise SystemExit(int(os.environ.get(f"FAKE_{key}_EXIT", "0")))
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def make_env(root: Path, *, install: bool = True) -> tuple[dict[str, str], Path]:
|
||||
binary = root / "CodexBar.app" / "Contents" / "Helpers" / "CodexBarCLI"
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_text(FAKE, encoding="utf-8")
|
||||
binary.chmod(0o755)
|
||||
env = os.environ.copy()
|
||||
env["CODEXBAR_SKIP_DISCOVERY"] = "1"
|
||||
env["CODEXBAR_TIMEOUT"] = "3"
|
||||
env["FAKE_LOG"] = str(root / "argv.log")
|
||||
env["FAKE_VERSION_STDOUT"] = "CodexBar 1.2.3\n"
|
||||
env["FAKE_VALIDATE_STDOUT"] = "[]"
|
||||
env["FAKE_PROVIDERS_STDOUT"] = json.dumps(
|
||||
[{"provider": "codex", "displayName": "Codex", "enabled": True}]
|
||||
)
|
||||
env["FAKE_USAGE_STDOUT"] = json.dumps(
|
||||
[
|
||||
{
|
||||
"provider": "codex",
|
||||
"source": "oauth",
|
||||
"usage": {
|
||||
"accountEmail": "alice@example.com",
|
||||
"accountOrganization": "Example Org",
|
||||
"identity": {"providerID": "codex", "accountID": "acct-123"},
|
||||
"primary": {"usedPercent": 42, "windowMinutes": 300},
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
if install:
|
||||
env["CODEXBAR_BIN"] = str(binary)
|
||||
else:
|
||||
env.pop("CODEXBAR_BIN", None)
|
||||
env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
return env, binary
|
||||
|
||||
|
||||
def helper(*args: str, env: dict[str, str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
class CodexBarSkillTests(unittest.TestCase):
|
||||
def test_help_is_small_and_read_only(self) -> None:
|
||||
result = helper("--help", env=os.environ.copy())
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("CodexBar read. JSON out. No writes.", result.stdout)
|
||||
self.assertNotIn("enable", result.stdout)
|
||||
self.assertNotIn("set-api-key", result.stdout)
|
||||
|
||||
def test_missing_binary_is_json(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env, _ = make_env(Path(tmp), install=False)
|
||||
result = helper("doctor", env=env)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(json.loads(result.stdout)["error"]["kind"], "missing")
|
||||
|
||||
def test_doctor_reports_version_and_raw_validation_shape(self) -> None:
|
||||
with tempfile.TemporaryDirectory(dir=Path.home()) as tmp:
|
||||
env, binary = make_env(Path(tmp))
|
||||
result = helper("doctor", env=env)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["version"], "CodexBar 1.2.3")
|
||||
self.assertEqual(payload["configIssues"], [])
|
||||
self.assertEqual(payload["binary"]["path"], "~" + str(binary)[len(str(Path.home())) :])
|
||||
|
||||
def test_providers_passes_upstream_json_without_second_schema(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env, _ = make_env(Path(tmp))
|
||||
result = helper("providers", env=env)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(
|
||||
json.loads(result.stdout),
|
||||
[{"provider": "codex", "displayName": "Codex", "enabled": True}],
|
||||
)
|
||||
|
||||
def test_usage_defaults_to_enabled_and_runs_from_any_cwd(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
env, _ = make_env(root)
|
||||
elsewhere = root / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
result = helper("usage", env=env, cwd=elsewhere)
|
||||
calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()]
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(calls, [["usage", "--format", "json", "--json-only"]])
|
||||
|
||||
def test_usage_scope_maps_to_upstream_cli(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
env, _ = make_env(root)
|
||||
self.assertEqual(helper("usage", "--all", env=env).returncode, 0)
|
||||
self.assertEqual(helper("usage", "--provider", "zai", env=env).returncode, 0)
|
||||
calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()]
|
||||
self.assertEqual(calls[0][-2:], ["--provider", "all"])
|
||||
self.assertEqual(calls[1][-2:], ["--provider", "zai"])
|
||||
|
||||
def test_usage_hides_identity_but_preserves_provider_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env, _ = make_env(Path(tmp))
|
||||
env["FAKE_USAGE_STDERR"] = (
|
||||
"Authorization: Bearer secret-token alice@example.com; token sk-live-prose-secret\n"
|
||||
)
|
||||
result = helper("usage", env=env)
|
||||
payload = json.loads(result.stdout)[0]
|
||||
self.assertEqual(payload["provider"], "codex")
|
||||
self.assertEqual(payload["usage"]["identity"]["providerID"], "codex")
|
||||
self.assertEqual(payload["usage"]["accountEmail"], "<redacted:email>")
|
||||
self.assertEqual(payload["usage"]["accountOrganization"], "<redacted:identity>")
|
||||
self.assertEqual(payload["usage"]["identity"]["accountID"], "<redacted:identity>")
|
||||
self.assertNotIn("secret-token", result.stderr)
|
||||
self.assertNotIn("sk-live-prose-secret", result.stderr)
|
||||
self.assertIn("<redacted:secret>", result.stderr)
|
||||
|
||||
def test_include_identities_never_exposes_secret_keys(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env, _ = make_env(Path(tmp))
|
||||
payload = json.loads(env["FAKE_USAGE_STDOUT"])
|
||||
payload[0]["usage"]["apiKey"] = "super-secret"
|
||||
env["FAKE_USAGE_STDOUT"] = json.dumps(payload)
|
||||
result = helper("usage", "--include-identities", env=env)
|
||||
usage = json.loads(result.stdout)[0]["usage"]
|
||||
self.assertEqual(usage["accountEmail"], "alice@example.com")
|
||||
self.assertEqual(usage["apiKey"], "<redacted:secret>")
|
||||
|
||||
def test_each_stream_is_capped_while_fully_drained(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
writer = root / "writer"
|
||||
writer.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import sys\n"
|
||||
"sys.stdout.buffer.write(b'o' * 2000000)\n"
|
||||
"sys.stderr.buffer.write(b'e' * 2000000)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
writer.chmod(0o755)
|
||||
result = run_process([str(writer)])
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(len(result.stdout), MAX_CAPTURE_BYTES)
|
||||
self.assertEqual(len(result.stderr), MAX_CAPTURE_BYTES)
|
||||
|
||||
def test_timeout_kills_process_group(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
env, _ = make_env(root)
|
||||
marker = root / "child-survived"
|
||||
env["CODEXBAR_TIMEOUT"] = "1"
|
||||
env["FAKE_SPAWN_CHILD"] = "1"
|
||||
env["FAKE_MARKER"] = str(marker)
|
||||
result = helper("usage", env=env)
|
||||
time.sleep(2.2)
|
||||
self.assertEqual(result.returncode, 124)
|
||||
self.assertEqual(json.loads(result.stdout)["error"]["kind"], "timeout")
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: qa-test
|
||||
description: "CodexBar live QA/e2e testing: run provider usage matrix checks, validate real app config, use Peekaboo for menu proof, use Browser Use/official docs for API spec or logged-in dashboard checks, and handle 1Password credentials safely."
|
||||
---
|
||||
|
||||
# CodexBar Live QA
|
||||
|
||||
Use for live provider testing, release smoke tests, menu verification, or debugging “provider works/fails” reports.
|
||||
|
||||
## Rules
|
||||
|
||||
- Work from the CodexBar repo checkout.
|
||||
- Use the packaged CLI first: `CodexBar.app/Contents/Helpers/CodexBarCLI`.
|
||||
- Do not use `CodexBar.app/Contents/MacOS/codexbar`; that is the app binary and may appear to hang as a CLI.
|
||||
- Never run broad `env`, `set`, or secret regex dumps.
|
||||
- Use `$one-password` for secrets: all `op` commands inside one persistent tmux session, service account first, no raw secret output.
|
||||
- Treat browser-cookie/keychain flows as prompt-risky. Prefer CLI/API-token checks and `KeychainNoUIQuery`-safe tests unless the user explicitly requested live UI.
|
||||
- For current API behavior, browse official provider docs only.
|
||||
|
||||
## CLI Matrix
|
||||
|
||||
Run the bundled script:
|
||||
|
||||
```bash
|
||||
.agents/skills/qa-test/scripts/live_provider_matrix.sh --enabled
|
||||
```
|
||||
|
||||
Useful modes:
|
||||
|
||||
```bash
|
||||
.agents/skills/qa-test/scripts/live_provider_matrix.sh --provider all
|
||||
.agents/skills/qa-test/scripts/live_provider_matrix.sh --providers openai,zai,deepseek
|
||||
.agents/skills/qa-test/scripts/live_provider_matrix.sh --default
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
|
||||
- `--enabled` asks `CodexBarCLI config providers` for enabled providers, honoring `CODEXBAR_CONFIG` and default toggles.
|
||||
- `--default` runs the app-facing default command with no provider override.
|
||||
- `--provider all` forces every registered provider and is expected to fail for providers without sessions/keys.
|
||||
- A green app config needs `--enabled` and `--default` clean; `--provider all` is a discovery/triage tool.
|
||||
|
||||
## Config QA
|
||||
|
||||
Validate config:
|
||||
|
||||
```bash
|
||||
CodexBar.app/Contents/Helpers/CodexBarCLI config validate
|
||||
stat -f '%Lp %N' "$HOME/.codexbar/config.json"
|
||||
```
|
||||
|
||||
Redact config shape:
|
||||
|
||||
```bash
|
||||
jq '(.providers // []) |= map(.apiKey = (if .apiKey then "<redacted>" else .apiKey end) |
|
||||
.secretKey = (if .secretKey then "<redacted>" else .secretKey end) |
|
||||
.cookieHeader = (if .cookieHeader then "<redacted>" else .cookieHeader end) |
|
||||
(if .id == "stepfun" and has("region") then .region = "<redacted>" else . end) |
|
||||
.tokenAccounts = (if .tokenAccounts then (.tokenAccounts | .accounts = (.accounts | map(.token = "<redacted>"))) else .tokenAccounts end))' \
|
||||
"$HOME/.codexbar/config.json"
|
||||
```
|
||||
|
||||
Before editing config, make a backup:
|
||||
|
||||
```bash
|
||||
cp "$HOME/.codexbar/config.json" "$HOME/.codexbar/config.pre-qa-$(date +%Y%m%d%H%M%S).json"
|
||||
chmod 600 "$HOME/.codexbar"/config.pre-qa-*.json
|
||||
```
|
||||
|
||||
## Live Menu QA
|
||||
|
||||
Use Peekaboo after CLI checks:
|
||||
|
||||
```bash
|
||||
pkill -x CodexBar || pkill -f 'CodexBar.app/Contents/MacOS/CodexBar' || true
|
||||
open -n "$PWD/CodexBar.app"
|
||||
peekaboo menu list-all --json | rg -i 'codexbar'
|
||||
peekaboo menu click-extra --title codexbar-merged --json
|
||||
screencapture -x /tmp/codexbar-live-menu.png
|
||||
```
|
||||
|
||||
Crop top-right menu if needed:
|
||||
|
||||
```bash
|
||||
sips --cropToHeightWidth 900 340 --cropOffset 20 2650 /tmp/codexbar-live-menu.png \
|
||||
--out /tmp/codexbar-live-menu-crop.png >/dev/null
|
||||
```
|
||||
|
||||
Verify visually with `view_image`. Confirm provider tabs/rows match enabled config and no failing provider dominates the first screen.
|
||||
|
||||
## Browser Use
|
||||
|
||||
Use `$browser-use` only when a logged-in dashboard, API key page, or provider docs need browser/profile state.
|
||||
|
||||
Existing Chrome path:
|
||||
|
||||
```bash
|
||||
mcporter call chrome-devtools.list_pages --args '{}' --output text
|
||||
mcporter call chrome-devtools.navigate_page --args '{"url":"https://provider.example"}' --output text
|
||||
mcporter call chrome-devtools.take_snapshot --args '{}' --output text
|
||||
```
|
||||
|
||||
If Browser Use is unavailable, say so and use web search for public official docs; do not substitute isolated Playwright for login/profile-dependent pages.
|
||||
|
||||
## Fix Triage
|
||||
|
||||
- Missing auth/session: configure key/session if available; otherwise leave provider disabled or report blocked auth.
|
||||
- Wrong provider API/spec: inspect official docs, then patch fetcher/settings/tests.
|
||||
- Provider key exists but live API rejects it: keep key stored if useful, disable provider if the menu would show a persistent error.
|
||||
- User-facing behavior changes need `CHANGELOG.md`.
|
||||
- Code fixes need focused tests, `make check`, `$autoreview`, and live CLI proof before landing.
|
||||
|
||||
## Known CodexBar QA Notes
|
||||
|
||||
- OpenAI Admin API key is the useful usage provider key. Project `OPENAI_API_KEY` values can fail legacy credit-balance fallback with 403.
|
||||
- Deepgram usage requires a key/project with Management API permissions; transcription-only keys can return 403.
|
||||
- Groq usage uses the Prometheus metrics API, not ordinary inference endpoints.
|
||||
- MiniMax pay-as-you-go API keys and Token Plan/Coding Plan keys are different; wrong key kind can leave usage unavailable.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "CodexBar QA Test"
|
||||
short_description: "Run live CodexBar CLI and menu QA safely."
|
||||
default_prompt: "Run CodexBar live QA with CLI, Peekaboo, browser docs, and 1Password-safe credential checks."
|
||||
@@ -0,0 +1,10 @@
|
||||
# API Spec Pointers
|
||||
|
||||
Use current official docs for provider API behavior. Prefer these searches/pages before patching fetchers:
|
||||
|
||||
- MiniMax: `https://platform.minimax.io/docs/llms.txt`; key types differ between pay-as-you-go API keys and Token Plan/Coding Plan keys.
|
||||
- Deepgram: `https://developers.deepgram.com/llms.txt`; usage/project APIs require Management permissions and project-scoped keys.
|
||||
- Groq: `https://console.groq.com/docs/prometheus-metrics`; usage metrics use `https://api.groq.com/v1/metrics/prometheus`.
|
||||
- LLM Proxy/LiteLLM: `https://docs.litellm.ai/`; CodexBar expects an LLM-API-Key-Proxy compatible `/v1/quota-stats` endpoint plus base URL.
|
||||
|
||||
When citing docs in a user-facing answer, browse the current page and include source links.
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}"
|
||||
TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v gtimeout || command -v timeout || true)}"
|
||||
WEB_TIMEOUT="${CODEXBAR_QA_WEB_TIMEOUT:-12}"
|
||||
CASE_TIMEOUT="${CODEXBAR_QA_CASE_TIMEOUT:-60}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
live_provider_matrix.sh --enabled
|
||||
live_provider_matrix.sh --default
|
||||
live_provider_matrix.sh --provider all
|
||||
live_provider_matrix.sh --providers openai,zai,deepseek
|
||||
|
||||
Environment:
|
||||
CODEXBAR_CLI=/path/to/CodexBarCLI
|
||||
CODEXBAR_CONFIG=/path/to/config.json
|
||||
CODEXBAR_QA_WEB_TIMEOUT=12
|
||||
CODEXBAR_QA_CASE_TIMEOUT=60
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [[ ! -x "$CLI" ]]; then
|
||||
echo "missing CodexBarCLI at $CLI" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$TIMEOUT_BIN" ]]; then
|
||||
echo "missing timeout command (install coreutils for gtimeout)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "missing node" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mode="${1:-}"
|
||||
shift || true
|
||||
|
||||
providers=()
|
||||
case "$mode" in
|
||||
--enabled)
|
||||
provider_status="$(mktemp)"
|
||||
provider_err="$(mktemp)"
|
||||
provider_list="$(mktemp)"
|
||||
if ! "$CLI" config providers --format json --json-only >"$provider_status" 2>"$provider_err"; then
|
||||
rm -f "$provider_status" "$provider_err" "$provider_list"
|
||||
echo "failed to list providers via CodexBarCLI config providers" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! node - "$provider_status" >"$provider_list" <<'NODE'; then
|
||||
const fs = require("fs");
|
||||
const path = process.argv[2];
|
||||
const raw = fs.readFileSync(path, "utf8").trim();
|
||||
const payload = JSON.parse(raw);
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error("config providers output is not an array");
|
||||
}
|
||||
for (const item of payload) {
|
||||
if (item && item.enabled === true && typeof item.provider === "string" && item.provider) {
|
||||
console.log(item.provider);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
rm -f "$provider_status" "$provider_err" "$provider_list"
|
||||
echo "failed to parse CodexBarCLI config providers output" >&2
|
||||
exit 2
|
||||
fi
|
||||
while IFS= read -r provider; do
|
||||
[[ -n "$provider" ]] && providers+=("$provider")
|
||||
done <"$provider_list"
|
||||
rm -f "$provider_status" "$provider_err" "$provider_list"
|
||||
if [[ "${#providers[@]}" -eq 0 ]]; then
|
||||
echo "no enabled providers found via CodexBarCLI config providers" >&2
|
||||
exit 2
|
||||
fi
|
||||
;;
|
||||
--default)
|
||||
providers=("__default__")
|
||||
;;
|
||||
--provider)
|
||||
if [[ -z "${1:-}" ]]; then
|
||||
echo "missing provider" >&2
|
||||
exit 2
|
||||
fi
|
||||
providers=("${1:-}")
|
||||
;;
|
||||
--providers)
|
||||
if [[ -z "${1:-}" ]]; then
|
||||
echo "missing providers" >&2
|
||||
exit 2
|
||||
fi
|
||||
IFS=',' read -r -a providers <<< "${1:-}"
|
||||
;;
|
||||
-h|--help|"")
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown mode: $mode" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
redact_node='
|
||||
const redact = s => String(s || "")
|
||||
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/g, "<email>")
|
||||
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED")
|
||||
.replace(/gsk_[A-Za-z0-9_-]{12,}/g, "gsk_REDACTED")
|
||||
.replace(/[A-Za-z0-9_-]{32,}/g, m => /[A-Za-z]/.test(m) && /[0-9]/.test(m) ? "<redacted-token>" : m);
|
||||
'
|
||||
|
||||
run_one() {
|
||||
local name="$1"
|
||||
shift
|
||||
local out err start end elapsed st node_status
|
||||
out="$(mktemp)"
|
||||
err="$(mktemp)"
|
||||
start="$(date +%s)"
|
||||
"$TIMEOUT_BIN" "$CASE_TIMEOUT" "$CLI" usage "$@" --format json --json-only --web-timeout "$WEB_TIMEOUT" >"$out" 2>"$err"
|
||||
st=$?
|
||||
end="$(date +%s)"
|
||||
elapsed=$((end - start))
|
||||
node - "$name" "$st" "$elapsed" "$out" "$err" <<NODE
|
||||
const fs = require("fs");
|
||||
$redact_node
|
||||
const [name, st, elapsed, outPath, errPath] = process.argv.slice(2);
|
||||
const raw = fs.readFileSync(outPath, "utf8").trim();
|
||||
const err = fs.readFileSync(errPath, "utf8").trim();
|
||||
let rows = [];
|
||||
let formatterFailed = false;
|
||||
try {
|
||||
const payload = raw ? JSON.parse(raw) : [];
|
||||
const arr = Array.isArray(payload) ? payload : [payload];
|
||||
for (const p of arr) {
|
||||
rows.push(
|
||||
\`\${p.provider || name}:\${p.error ? "fail" : "ok"}:source=\${p.source || "unknown"}\` +
|
||||
(p.account ? \`,account=\${redact(p.account)}\` : "") +
|
||||
(p.usage ? ",usage=yes" : "") +
|
||||
(p.credits ? ",credits=yes" : "") +
|
||||
(p.error ? \`,error=\${redact(p.error.message).slice(0, 180)}\` : "")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
formatterFailed = true;
|
||||
rows.push(\`\${name}:parse-fail:error=\${redact(error.message)} stdout=\${redact(raw).slice(0, 200)} stderr=\${redact(err).slice(0, 200)}\`);
|
||||
}
|
||||
if (!rows.length) {
|
||||
formatterFailed = true;
|
||||
rows.push(\`\${name}:empty:stderr=\${redact(err).slice(0, 200)}\`);
|
||||
}
|
||||
console.log(\`TEST \${name} exit=\${st} elapsed=\${elapsed}s :: \${rows.join(" | ")}\`);
|
||||
if (formatterFailed) process.exit(1);
|
||||
NODE
|
||||
node_status=$?
|
||||
rm -f "$out" "$err"
|
||||
if [[ "$node_status" -ne 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
return "$st"
|
||||
}
|
||||
|
||||
overall=0
|
||||
ran=0
|
||||
for provider in "${providers[@]}"; do
|
||||
[[ -z "$provider" ]] && continue
|
||||
ran=$((ran + 1))
|
||||
if [[ "$provider" == "__default__" ]]; then
|
||||
run_one default || overall=1
|
||||
elif [[ "$provider" == "all" ]]; then
|
||||
run_one all --provider all || overall=1
|
||||
else
|
||||
run_one "$provider" --provider "$provider" || overall=1
|
||||
fi
|
||||
done
|
||||
if [[ "$ran" -eq 0 ]]; then
|
||||
echo "no provider cases ran" >&2
|
||||
exit 2
|
||||
fi
|
||||
exit "$overall"
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: release-codexbar
|
||||
description: "CodexBar release: versioning, notarization, appcast, Homebrew, post-release bump."
|
||||
---
|
||||
|
||||
# CodexBar Release
|
||||
|
||||
Use for releasing signed/notarized macOS apps, especially repos with Sparkle appcasts and Homebrew casks.
|
||||
|
||||
## Start
|
||||
|
||||
1. Work from the app repo unless asked otherwise.
|
||||
2. Check repo state, current version, latest tag/release, and release docs/scripts.
|
||||
3. Confirm `CHANGELOG.md` is complete, user-facing, deduped, and dated for the release.
|
||||
4. Prefer the repo release script; patch small script/test blockers instead of bypassing the release path.
|
||||
5. Never print key material. Keep 1Password references and local key paths as references only.
|
||||
6. Load `$release-private` if it exists before resolving Peter-owned credential locators.
|
||||
|
||||
## Key Material
|
||||
|
||||
Use `$one-password` for secret handling. `op` only in tmux/persistent shell; no broad `env`, `set`, `export -p`, or secret scans.
|
||||
|
||||
Known App Store Connect shape:
|
||||
|
||||
- fields: `private_key_p8`, `key_id`, `issuer_id`
|
||||
- keep all three fields from the same 1Password item; do not mix with stale values from `~/.profile`
|
||||
- resolve Peter-owned item refs from `$release-private`
|
||||
|
||||
Known Sparkle key:
|
||||
|
||||
- resolve the private key file from `$release-private`
|
||||
- pass as `SPARKLE_PRIVATE_KEY_FILE`
|
||||
|
||||
Safe env file pattern:
|
||||
|
||||
```text
|
||||
APP_STORE_CONNECT_API_KEY_P8=<1Password ref from release-private>
|
||||
APP_STORE_CONNECT_KEY_ID=<1Password ref from release-private>
|
||||
APP_STORE_CONNECT_ISSUER_ID=<1Password ref from release-private>
|
||||
SPARKLE_PRIVATE_KEY_FILE=<path from release-private>
|
||||
```
|
||||
|
||||
Run with `op run --account my.1password.com --env-file <file> -- <script>`, then delete the temp env file.
|
||||
|
||||
## CodexBar
|
||||
|
||||
Paths:
|
||||
|
||||
- repo: `~/Projects/codexbar`
|
||||
- release script: `Scripts/release.sh`
|
||||
- signing/notarization: `Scripts/sign-and-notarize.sh`
|
||||
- appcast: `Scripts/make_appcast.sh`, `appcast.xml`
|
||||
- release assets: `CodexBar-macos-universal-<version>.zip`, `CodexBar-macos-universal-<version>.dSYM.zip`
|
||||
- packaged app: `CodexBar.app`
|
||||
- version file: `version.env`
|
||||
- changelog: `CHANGELOG.md`
|
||||
- Homebrew tap: `~/Projects/homebrew-tap`
|
||||
- cask: `~/Projects/homebrew-tap/Casks/codexbar.rb`
|
||||
- formula: `~/Projects/homebrew-tap/Formula/codexbar.rb`
|
||||
- CLI release workflow: `.github/workflows/release-cli.yml`
|
||||
|
||||
Normal release:
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s codexbar-release 'op run --account my.1password.com --env-file /tmp/codexbar-release-op.env -- Scripts/release.sh'
|
||||
tmux attach -t codexbar-release
|
||||
```
|
||||
|
||||
If notarization fails with `401 Unauthenticated`, rerun using all three App Store Connect fields from the 1Password item above. Mismatched `key_id` / `issuer_id` from `~/.profile` can cause this.
|
||||
|
||||
If widget metadata generation times out, `CODEXBAR_WIDGET_METADATA_TIMEOUT_SECONDS=600` is a known-good floor.
|
||||
|
||||
CodexBar CLI tarballs are not produced by `Scripts/release.sh` itself. The GitHub release event triggers `.github/workflows/release-cli.yml`, which builds and uploads:
|
||||
|
||||
- `CodexBarCLI-v<version>-macos-arm64.tar.gz`
|
||||
- `CodexBarCLI-v<version>-macos-x86_64.tar.gz`
|
||||
- `CodexBarCLI-v<version>-linux-aarch64.tar.gz`
|
||||
- `CodexBarCLI-v<version>-linux-x86_64.tar.gz`
|
||||
- matching `.sha256` files
|
||||
|
||||
If the workflow fails only in `update-homebrew-tap` with GitHub API rate limiting, the CLI assets may already be uploaded. Verify assets live, then update `Formula/codexbar.rb` manually from the tarball checksums.
|
||||
|
||||
## Verify
|
||||
|
||||
Release is not done until the published chain checks out:
|
||||
|
||||
```bash
|
||||
gh release view v<VERSION> --json tagName,name,isDraft,isPrerelease,url,assets,body
|
||||
Scripts/check-release-assets.sh v<VERSION>
|
||||
python3 - <<'PY'
|
||||
import xml.etree.ElementTree as ET
|
||||
ns={'sparkle':'http://www.andymatuschak.org/xml-namespaces/sparkle'}
|
||||
root=ET.parse('appcast.xml').getroot()
|
||||
item=root.find('channel').find('item')
|
||||
enc=item.find('enclosure')
|
||||
print(item.findtext('title'))
|
||||
print(item.findtext('sparkle:version', namespaces=ns))
|
||||
print(item.findtext('sparkle:shortVersionString', namespaces=ns))
|
||||
print(enc.attrib.get('url'))
|
||||
print(enc.attrib.get('length'))
|
||||
print(bool(enc.attrib.get('{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature')))
|
||||
PY
|
||||
codesign --verify --deep --strict --verbose=2 CodexBar.app
|
||||
spctl --assess --type execute --verbose CodexBar.app
|
||||
```
|
||||
|
||||
For Homebrew:
|
||||
|
||||
```bash
|
||||
shasum -a 256 CodexBar-macos-universal-<VERSION>.zip
|
||||
cd /Users/steipete/Projects/homebrew-tap
|
||||
python3 .github/scripts/update_formula.py --formula codexbar --tag v<VERSION> --repository steipete/CodexBar --artifact-template 'CodexBarCLI-{tag}-{target}.tar.gz' --target-aliases 'darwin_arm64=macos-arm64,darwin_amd64=macos-x86_64,linux_arm64=linux-aarch64,linux_amd64=linux-x86_64'
|
||||
brew fetch --cask --force --retry codexbar
|
||||
brew fetch --formula --force --retry steipete/tap/codexbar
|
||||
```
|
||||
|
||||
Update the cask when app zip assets exist. Update the formula only when standalone CLI tarballs for that version exist.
|
||||
|
||||
Tap audit can be noisy from unrelated formulae; keep evidence specific to the app cask.
|
||||
|
||||
## Closeout
|
||||
|
||||
1. Create/push tag and GitHub release through the release script.
|
||||
2. Verify appcast points to the new GitHub release asset with signature and length.
|
||||
3. Update/push the Homebrew cask if the app zip changed.
|
||||
4. Bump the app repo to next patch `Unreleased`:
|
||||
- `version.env`: next `MARKETING_VERSION`, next `BUILD_NUMBER`
|
||||
- `CHANGELOG.md`: top `## <next> — Unreleased`
|
||||
5. Commit, push, then pull `--ff-only`.
|
||||
6. Restart the local app from the packaged bundle and verify the running bundle version.
|
||||
7. Check no release/notary/op temp sessions or temp env files remain.
|
||||
|
||||
CodexBar restart:
|
||||
|
||||
```bash
|
||||
pkill -x CodexBar || pkill -f CodexBar.app || true
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
open -n CodexBar.app
|
||||
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' CodexBar.app/Contents/Info.plist
|
||||
/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' CodexBar.app/Contents/Info.plist
|
||||
```
|
||||
@@ -0,0 +1,320 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
SWIFT_VERSION: 6.3.3
|
||||
SWIFTLY_VERSION: 1.1.3
|
||||
SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
macos-tests: ${{ steps.macos-tests.outputs.macos-tests }}
|
||||
macos-tests-reason: ${{ steps.macos-tests.outputs.macos-tests-reason }}
|
||||
changed-path-count: ${{ steps.macos-tests.outputs.changed-path-count }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect macOS test impact
|
||||
id: macos-tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
base_sha="${{ github.event.pull_request.base.sha }}"
|
||||
else
|
||||
base_sha="${{ github.event.before }}"
|
||||
fi
|
||||
|
||||
changed_paths="${RUNNER_TEMP}/changed-paths.txt"
|
||||
if [[ -z "$base_sha" || "$base_sha" =~ ^0+$ ]]; then
|
||||
git ls-files | awk '{ print "A\t" $0 }' > "$changed_paths"
|
||||
elif ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Base commit ${base_sha} is unavailable; running macOS tests conservatively."
|
||||
git ls-files | awk '{ print "A\t" $0 }' > "$changed_paths"
|
||||
else
|
||||
git diff --name-status "$base_sha" "$GITHUB_SHA" > "$changed_paths"
|
||||
fi
|
||||
|
||||
printf 'Changed paths:\n'
|
||||
sed 's/^/- /' "$changed_paths"
|
||||
./Scripts/ci_macos_test_gate.sh "$changed_paths"
|
||||
|
||||
- name: Summarize macOS test gate
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
env:
|
||||
MACOS_TESTS: ${{ steps.macos-tests.outputs.macos-tests }}
|
||||
MACOS_TESTS_REASON: ${{ steps.macos-tests.outputs.macos-tests-reason }}
|
||||
CHANGED_PATH_COUNT: ${{ steps.macos-tests.outputs.changed-path-count }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
reason="${MACOS_TESTS_REASON:-<unset>}"
|
||||
reason="${reason//|/\\|}"
|
||||
{
|
||||
printf '### macOS test gate\n\n'
|
||||
printf '| Field | Value |\n'
|
||||
printf '| --- | --- |\n'
|
||||
printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-<unset>}"
|
||||
printf '| Reason | %s |\n' "$reason"
|
||||
printf '| Changed path entries | `%s` |\n' "${CHANGED_PATH_COUNT:-<unset>}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install lint tools
|
||||
run: ./Scripts/install_lint_tools.sh swiftlint
|
||||
|
||||
- name: Lint
|
||||
run: ./Scripts/lint.sh lint-linux
|
||||
|
||||
swift-test-macos:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.macos-tests == 'true' }}
|
||||
runs-on: macos-15-intel
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Two shards amortize SwiftPM's per-shard discovery/cold build.
|
||||
# Each shard remains within the 50-minute Swift Test timeout.
|
||||
shard-index: [0, 1]
|
||||
shard-count: [2]
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Select Xcode 26.3 or 26.2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Both versions are part of the official macOS 15 runner image.
|
||||
for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
sudo xcode-select -s "${candidate}/Contents/Developer"
|
||||
echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]]
|
||||
/usr/bin/xcodebuild -version
|
||||
|
||||
- name: Swift toolchain version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
swift --version
|
||||
swift package --version
|
||||
|
||||
- name: Check app locales and Swift formatting
|
||||
if: ${{ matrix.shard-index == 0 }}
|
||||
run: ./Scripts/lint.sh lint-macos
|
||||
|
||||
- name: Swift Test
|
||||
# Keep small batches to reduce SwiftPM process overhead without returning to one aggregate run.
|
||||
timeout-minutes: 50
|
||||
run: |
|
||||
CODEXBAR_TEST_GROUP_SIZE=4 \
|
||||
CODEXBAR_TEST_SUITE_TIMEOUT=120 \
|
||||
CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES=0 \
|
||||
CODEXBAR_TEST_SHARD_INDEX=${{ matrix.shard-index }} \
|
||||
CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }} \
|
||||
./Scripts/test.sh
|
||||
|
||||
- name: Summarize macOS shard
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
env:
|
||||
SHARD_INDEX: ${{ matrix.shard-index }}
|
||||
SHARD_COUNT: ${{ matrix.shard-count }}
|
||||
RUNS_LINT_MACOS: ${{ matrix.shard-index == 0 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
display_shard_index=$((SHARD_INDEX + 1))
|
||||
xcode_version="$(/usr/bin/xcodebuild -version 2>/dev/null | tr '\n' ' ' || true)"
|
||||
{
|
||||
printf '### macOS Swift shard\n\n'
|
||||
printf '| Field | Value |\n'
|
||||
printf '| --- | --- |\n'
|
||||
printf '| Shard | `%s / %s` |\n' "$display_shard_index" "$SHARD_COUNT"
|
||||
printf '| Runner | `%s` |\n' "${RUNNER_NAME:-unknown}"
|
||||
printf '| Xcode | `%s` |\n' "${xcode_version:-unknown}"
|
||||
printf '| Runs lint-macos | `%s` |\n' "$RUNS_LINT_MACOS"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
lint-build-test:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- swift-test-macos
|
||||
if: ${{ always() && !cancelled() }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Verify lint and macOS test jobs
|
||||
run: |
|
||||
./Scripts/ci_verify_test_jobs.sh \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.changes.result }}" \
|
||||
"${{ needs.changes.outputs.macos-tests }}" \
|
||||
"${{ needs.swift-test-macos.result }}"
|
||||
|
||||
- name: Summarize aggregate CI gate
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
env:
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
MACOS_TESTS: ${{ needs.changes.outputs.macos-tests }}
|
||||
MACOS_TESTS_REASON: ${{ needs.changes.outputs.macos-tests-reason }}
|
||||
MACOS_RESULT: ${{ needs.swift-test-macos.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
reason="${MACOS_TESTS_REASON:-<unset>}"
|
||||
reason="${reason//|/\\|}"
|
||||
{
|
||||
printf '### Aggregate CI gate\n\n'
|
||||
printf '| Field | Value |\n'
|
||||
printf '| --- | --- |\n'
|
||||
printf '| lint result | `%s` |\n' "$LINT_RESULT"
|
||||
printf '| changes result | `%s` |\n' "$CHANGES_RESULT"
|
||||
printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-<unset>}"
|
||||
printf '| macOS gate reason | %s |\n' "$reason"
|
||||
printf '| swift-test-macos result | `%s` |\n' "$MACOS_RESULT"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
build-linux-cli:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runs-on: ubuntu-24.04
|
||||
- name: linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Runner info
|
||||
run: |
|
||||
set -euo pipefail
|
||||
uname -a
|
||||
uname -m
|
||||
|
||||
- name: Restore Swift toolchain cache
|
||||
id: swift-toolchain-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.local/share/swiftly
|
||||
key: swift-${{ runner.os }}-${{ runner.arch }}-${{ env.SWIFT_VERSION }}-swiftly-${{ env.SWIFTLY_VERSION }}
|
||||
|
||||
- name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
missing_packages=()
|
||||
for package in ca-certificates gpg libcurl4-openssl-dev; do
|
||||
if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q "install ok installed"; then
|
||||
missing_packages+=("$package")
|
||||
fi
|
||||
done
|
||||
if [[ "${#missing_packages[@]}" -gt 0 ]]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y "${missing_packages[@]}"
|
||||
fi
|
||||
|
||||
SWIFTLY_ARCH="$(uname -m)"
|
||||
SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz"
|
||||
SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig"
|
||||
SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly"
|
||||
SWIFTLY_BIN_DIR="$HOME/.local/bin"
|
||||
SWIFT_GNUPGHOME="$(mktemp -d)"
|
||||
SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc"
|
||||
POST_INSTALL_SCRIPT="$(mktemp)"
|
||||
|
||||
mkdir -p "$SWIFTLY_BIN_DIR"
|
||||
chmod 700 "$SWIFT_GNUPGHOME"
|
||||
echo "Swift toolchain cache hit: ${{ steps.swift-toolchain-cache.outputs.cache-hit }}"
|
||||
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE"
|
||||
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE"
|
||||
curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS"
|
||||
GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS"
|
||||
SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \
|
||||
--verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)"
|
||||
printf '%s\n' "$SIGNATURE_STATUS"
|
||||
grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS"
|
||||
tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp
|
||||
/tmp/swiftly init --assume-yes --skip-install
|
||||
|
||||
. "$SWIFTLY_HOME_DIR/env.sh"
|
||||
echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH"
|
||||
echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV"
|
||||
echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"
|
||||
if [[ -s "$POST_INSTALL_SCRIPT" ]]; then
|
||||
sudo apt-get update
|
||||
sudo bash "$POST_INSTALL_SCRIPT"
|
||||
fi
|
||||
|
||||
hash -r
|
||||
swift --version
|
||||
|
||||
- name: Build CodexBarCLI (release, static Swift stdlib)
|
||||
run: swift build -c release --product CodexBarCLI --static-swift-stdlib
|
||||
|
||||
- name: Swift Test (Linux only)
|
||||
run: swift test --parallel
|
||||
|
||||
- name: Smoke test CodexBarCLI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BIN_DIR="$(swift build -c release --product CodexBarCLI --static-swift-stdlib --show-bin-path)"
|
||||
BIN="$BIN_DIR/CodexBarCLI"
|
||||
"$BIN" --help >/dev/null
|
||||
"$BIN" --version >/dev/null
|
||||
if "$BIN" usage --provider codex --web >/dev/null 2>&1; then
|
||||
echo "Expected --web to fail on Linux"
|
||||
exit 1
|
||||
fi
|
||||
"$BIN" usage --provider codex --web 2>&1 | tee /tmp/codexbarcli-stderr.txt >/dev/null || true
|
||||
grep -q "macOS" /tmp/codexbarcli-stderr.txt
|
||||
|
||||
- name: Summarize Linux CLI build
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
env:
|
||||
MATRIX_NAME: ${{ matrix.name }}
|
||||
MATRIX_RUNS_ON: ${{ matrix.runs-on }}
|
||||
SWIFT_CACHE_HIT: ${{ steps.swift-toolchain-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
printf '### Linux CLI build\n\n'
|
||||
printf '| Field | Value |\n'
|
||||
printf '| --- | --- |\n'
|
||||
printf '| Matrix | `%s` |\n' "$MATRIX_NAME"
|
||||
printf '| Runner label | `%s` |\n' "$MATRIX_RUNS_ON"
|
||||
printf '| Swift version | `%s` |\n' "$SWIFT_VERSION"
|
||||
printf '| Swift toolchain cache hit | `%s` |\n' "${SWIFT_CACHE_HIT:-false}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,497 @@
|
||||
name: Release CLI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag/version to package for manual artifact builds; manual runs do not publish."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-cli:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runs-on: ubuntu-24.04
|
||||
platform: linux
|
||||
asset-platform: linux
|
||||
asset-arch: x86_64
|
||||
build-arch: ""
|
||||
static-swift-stdlib: true
|
||||
swift-sdk: ""
|
||||
swift-sdk-triple: ""
|
||||
swift-sdk-arch: ""
|
||||
file-arch-pattern: x86-64
|
||||
- name: linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
platform: linux
|
||||
asset-platform: linux
|
||||
asset-arch: aarch64
|
||||
build-arch: ""
|
||||
static-swift-stdlib: true
|
||||
swift-sdk: ""
|
||||
swift-sdk-triple: ""
|
||||
swift-sdk-arch: ""
|
||||
file-arch-pattern: aarch64
|
||||
- name: linux-musl-x64
|
||||
runs-on: ubuntu-24.04
|
||||
platform: linux
|
||||
asset-platform: linux-musl
|
||||
asset-arch: x86_64
|
||||
build-arch: ""
|
||||
static-swift-stdlib: false
|
||||
swift-sdk: swift-6.2.1-RELEASE_static-linux-0.0.1
|
||||
swift-sdk-triple: x86_64-swift-linux-musl
|
||||
swift-sdk-arch: x86_64
|
||||
file-arch-pattern: x86-64
|
||||
- name: linux-musl-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
platform: linux
|
||||
asset-platform: linux-musl
|
||||
asset-arch: aarch64
|
||||
build-arch: ""
|
||||
static-swift-stdlib: false
|
||||
swift-sdk: swift-6.2.1-RELEASE_static-linux-0.0.1
|
||||
swift-sdk-triple: aarch64-swift-linux-musl
|
||||
swift-sdk-arch: aarch64
|
||||
file-arch-pattern: aarch64
|
||||
- name: macos-arm64
|
||||
runs-on: macos-15
|
||||
platform: macos
|
||||
asset-platform: macos
|
||||
asset-arch: arm64
|
||||
build-arch: arm64
|
||||
static-swift-stdlib: false
|
||||
swift-sdk: ""
|
||||
swift-sdk-triple: ""
|
||||
swift-sdk-arch: ""
|
||||
file-arch-pattern: ""
|
||||
- name: macos-x86_64
|
||||
runs-on: macos-15-intel
|
||||
platform: macos
|
||||
asset-platform: macos
|
||||
asset-arch: x86_64
|
||||
build-arch: x86_64
|
||||
static-swift-stdlib: false
|
||||
swift-sdk: ""
|
||||
swift-sdk-triple: ""
|
||||
swift-sdk-arch: ""
|
||||
file-arch-pattern: ""
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
SWIFT_VERSION: 6.2.1
|
||||
SWIFTLY_VERSION: 1.1.3
|
||||
SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069
|
||||
SWIFT_STATIC_LINUX_SDK_URL: https://download.swift.org/swift-6.2.1-release/static-sdk/swift-6.2.1-RELEASE/swift-6.2.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz
|
||||
SWIFT_STATIC_LINUX_SDK_CHECKSUM: 08e1939a504e499ec871b36826569173103e4562769e12b9b8c2a50f098374ad
|
||||
SQLITE_AMALGAMATION_VERSION: "3530300"
|
||||
SQLITE_AMALGAMATION_SHA3_256: d45c688a8cb23f68611a894a756a12d7eb6ab6e9e2468ca70adbeab3808b5ab9
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Select Xcode 26.3 or 26.2
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Both versions are part of the official macOS 15 runner image.
|
||||
for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
sudo xcode-select -s "${candidate}/Contents/Developer"
|
||||
echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]]
|
||||
/usr/bin/xcodebuild -version
|
||||
|
||||
- name: Runner info
|
||||
run: |
|
||||
set -euo pipefail
|
||||
uname -a
|
||||
uname -m
|
||||
swift --version
|
||||
|
||||
- name: Validate release tag
|
||||
if: github.event_name == 'release' || inputs.tag != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$RELEASE_TAG" ]]; then
|
||||
echo "Missing release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z._-]+$ ]]; then
|
||||
echo "Invalid release tag: $RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v gpg >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates gpg
|
||||
fi
|
||||
|
||||
SWIFTLY_ARCH="$(uname -m)"
|
||||
SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz"
|
||||
SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig"
|
||||
SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly"
|
||||
SWIFTLY_BIN_DIR="$HOME/.local/bin"
|
||||
SWIFT_GNUPGHOME="$(mktemp -d)"
|
||||
SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc"
|
||||
POST_INSTALL_SCRIPT="$(mktemp)"
|
||||
|
||||
mkdir -p "$SWIFTLY_BIN_DIR"
|
||||
chmod 700 "$SWIFT_GNUPGHOME"
|
||||
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE"
|
||||
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE"
|
||||
curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS"
|
||||
GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS"
|
||||
SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \
|
||||
--verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)"
|
||||
printf '%s\n' "$SIGNATURE_STATUS"
|
||||
grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS"
|
||||
tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp
|
||||
/tmp/swiftly init --assume-yes --skip-install
|
||||
|
||||
. "$SWIFTLY_HOME_DIR/env.sh"
|
||||
echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH"
|
||||
echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV"
|
||||
echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"
|
||||
if [[ -s "$POST_INSTALL_SCRIPT" ]]; then
|
||||
sudo apt-get update
|
||||
sudo bash "$POST_INSTALL_SCRIPT"
|
||||
fi
|
||||
|
||||
hash -r
|
||||
swift --version
|
||||
|
||||
- name: Install Swift Static Linux SDK
|
||||
if: matrix.swift-sdk != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
swift sdk install "$SWIFT_STATIC_LINUX_SDK_URL" --checksum "$SWIFT_STATIC_LINUX_SDK_CHECKSUM"
|
||||
swift sdk list | grep -Fx "${{ matrix.swift-sdk }}"
|
||||
|
||||
sdk_root="$(
|
||||
find "$HOME" -type d -path "*/${{ matrix.swift-sdk }}.artifactbundle/${{ matrix.swift-sdk }}/swift-linux-musl" | head -n1
|
||||
)"
|
||||
if [[ -z "$sdk_root" ]]; then
|
||||
echo "Swift SDK root not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$sdk_root/swift-sdk.json" "${{ matrix.swift-sdk-triple }}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
sdk_json_path, target_triple = sys.argv[1], sys.argv[2]
|
||||
with open(sdk_json_path, encoding="utf-8") as handle:
|
||||
sdk_json = json.load(handle)
|
||||
|
||||
target_triples = sdk_json.get("targetTriples", {})
|
||||
if target_triple not in target_triples:
|
||||
raise SystemExit(f"Swift SDK target triple not found: {target_triple}")
|
||||
|
||||
sdk_json["targetTriples"] = {target_triple: target_triples[target_triple]}
|
||||
with open(sdk_json_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(sdk_json, handle, indent=2)
|
||||
handle.write("\n")
|
||||
PY
|
||||
|
||||
for sdk_arch in "$sdk_root"/musl-1.2.5.sdk/*; do
|
||||
if [[ "$(basename "$sdk_arch")" != "${{ matrix.swift-sdk-arch }}" ]]; then
|
||||
rm -rf "$sdk_arch"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build static SQLite for musl SDK
|
||||
if: matrix.swift-sdk != ''
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
missing_packages=()
|
||||
for tool in clang openssl unzip; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing_packages+=("$tool")
|
||||
fi
|
||||
done
|
||||
if [[ "${#missing_packages[@]}" -gt 0 ]]; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y "${missing_packages[@]}"
|
||||
fi
|
||||
|
||||
sqlite_zip="$RUNNER_TEMP/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip"
|
||||
sqlite_src="$RUNNER_TEMP/sqlite-src"
|
||||
sqlite_out="$RUNNER_TEMP/sqlite-${{ matrix.swift-sdk-triple }}"
|
||||
|
||||
curl -fsSL "https://www.sqlite.org/2026/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip" -o "$sqlite_zip"
|
||||
actual_sha3="$(openssl dgst -sha3-256 "$sqlite_zip" | awk '{print $NF}')"
|
||||
if [[ "$actual_sha3" != "$SQLITE_AMALGAMATION_SHA3_256" ]]; then
|
||||
echo "SQLite amalgamation checksum mismatch: $actual_sha3" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$sqlite_src" "$sqlite_out"
|
||||
mkdir -p "$sqlite_src" "$sqlite_out/build" "$sqlite_out/lib"
|
||||
unzip -q "$sqlite_zip" -d "$sqlite_src"
|
||||
|
||||
sdk_bundle="$(
|
||||
find "$HOME" -type d -name "${{ matrix.swift-sdk }}.artifactbundle" | head -n1
|
||||
)"
|
||||
if [[ -z "$sdk_bundle" ]]; then
|
||||
echo "Swift SDK artifact bundle not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
sysroot="$sdk_bundle/${{ matrix.swift-sdk }}/swift-linux-musl/musl-1.2.5.sdk/${{ matrix.swift-sdk-arch }}"
|
||||
if [[ ! -d "$sysroot" ]]; then
|
||||
echo "Swift SDK sysroot not found: $sysroot" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sqlite_amalgamation="$sqlite_src/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}"
|
||||
mkdir -p "$sysroot/usr/include"
|
||||
install -m 0644 "$sqlite_amalgamation/sqlite3.h" "$sysroot/usr/include/sqlite3.h"
|
||||
|
||||
clang \
|
||||
-target "${{ matrix.swift-sdk-triple }}" \
|
||||
--sysroot="$sysroot" \
|
||||
-O2 \
|
||||
-DSQLITE_OMIT_LOAD_EXTENSION=1 \
|
||||
-c "$sqlite_amalgamation/sqlite3.c" \
|
||||
-o "$sqlite_out/build/sqlite3.o"
|
||||
llvm-ar crs "$sqlite_out/lib/libsqlite3.a" "$sqlite_out/build/sqlite3.o"
|
||||
|
||||
echo "CODEXBAR_SQLITE3_LIB_DIR=$sqlite_out/lib" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build CodexBarCLI (release)
|
||||
id: build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_ARGS=(swift build -c release --product CodexBarCLI)
|
||||
if [[ -n "${{ matrix.swift-sdk }}" ]]; then
|
||||
BUILD_ARGS+=(--swift-sdk "${{ matrix.swift-sdk }}" --triple "${{ matrix.swift-sdk-triple }}")
|
||||
elif [[ -n "${{ matrix.build-arch }}" ]]; then
|
||||
BUILD_ARGS+=(--arch "${{ matrix.build-arch }}")
|
||||
fi
|
||||
if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then
|
||||
BUILD_ARGS+=(--static-swift-stdlib)
|
||||
fi
|
||||
"${BUILD_ARGS[@]}"
|
||||
|
||||
SHOW_BIN_ARGS=(swift build -c release --product CodexBarCLI --show-bin-path)
|
||||
if [[ -n "${{ matrix.swift-sdk }}" ]]; then
|
||||
SHOW_BIN_ARGS+=(--swift-sdk "${{ matrix.swift-sdk }}" --triple "${{ matrix.swift-sdk-triple }}")
|
||||
elif [[ -n "${{ matrix.build-arch }}" ]]; then
|
||||
SHOW_BIN_ARGS+=(--arch "${{ matrix.build-arch }}")
|
||||
fi
|
||||
if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then
|
||||
SHOW_BIN_ARGS+=(--static-swift-stdlib)
|
||||
fi
|
||||
|
||||
echo "bin_dir=$("${SHOW_BIN_ARGS[@]}")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Smoke test CodexBarCLI
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BIN_DIR="${{ steps.build.outputs.bin_dir }}"
|
||||
BIN="$BIN_DIR/CodexBarCLI"
|
||||
run_with_timeout() {
|
||||
local output="$1"
|
||||
shift
|
||||
"$@" > "$output" &
|
||||
local pid=$!
|
||||
local run_status=
|
||||
for _ in {1..20}; do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
set +e
|
||||
wait "$pid"
|
||||
run_status=$?
|
||||
set -e
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ -z "$run_status" ]]; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
echo "$* timed out." >&2
|
||||
exit 124
|
||||
fi
|
||||
if [[ "$run_status" -ne 0 ]]; then
|
||||
cat "$output" >&2 || true
|
||||
exit "$run_status"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "BIN=$BIN"
|
||||
file "$BIN"
|
||||
if [[ "${{ matrix.platform }}" == "macos" ]]; then
|
||||
lipo -archs "$BIN" | tr ' ' '\n' | grep -Fx "${{ matrix.asset-arch }}"
|
||||
run_with_timeout "$RUNNER_TEMP/codexbar-cli-smoke-${{ matrix.name }}.txt" "$BIN" config validate --format json
|
||||
elif [[ -n "${{ matrix.swift-sdk }}" ]]; then
|
||||
run_with_timeout "$RUNNER_TEMP/codexbar-cli-help-${{ matrix.name }}.txt" "$BIN" --help
|
||||
run_with_timeout "$RUNNER_TEMP/codexbar-cli-config-${{ matrix.name }}.json" "$BIN" config validate --format json
|
||||
file "$BIN" | grep -q "${{ matrix.file-arch-pattern }}"
|
||||
file "$BIN" | grep -q "statically linked"
|
||||
else
|
||||
run_with_timeout "$RUNNER_TEMP/codexbar-cli-help-${{ matrix.name }}.txt" "$BIN" --help
|
||||
file "$BIN" | grep -q "${{ matrix.file-arch-pattern }}"
|
||||
fi
|
||||
printf '%s\n' "${RELEASE_TAG#v}" > "$BIN_DIR/VERSION"
|
||||
VERSION_OUTPUT="$RUNNER_TEMP/codexbar-cli-version-${{ matrix.name }}.txt"
|
||||
run_with_timeout "$VERSION_OUTPUT" "$BIN" --version
|
||||
grep -Fx "CodexBar ${RELEASE_TAG#v}" "$VERSION_OUTPUT"
|
||||
rm "$BIN_DIR/VERSION"
|
||||
|
||||
- name: Package
|
||||
id: pkg
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
REF_NAME="${RELEASE_TAG}"
|
||||
if [[ -z "$REF_NAME" ]]; then
|
||||
echo "Missing release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
SAFE_REF_NAME="${REF_NAME//\//-}"
|
||||
|
||||
BIN_DIR="${{ steps.build.outputs.bin_dir }}"
|
||||
OUT_DIR="$(mktemp -d)"
|
||||
install -m 0755 "$BIN_DIR/CodexBarCLI" "$OUT_DIR/CodexBarCLI"
|
||||
ln -s "CodexBarCLI" "$OUT_DIR/codexbar"
|
||||
printf '%s\n' "${SAFE_REF_NAME#v}" > "$OUT_DIR/VERSION"
|
||||
|
||||
ASSET="CodexBarCLI-${SAFE_REF_NAME}-${{ matrix.asset-platform }}-${{ matrix.asset-arch }}.tar.gz"
|
||||
(cd "$OUT_DIR" && tar czf "$ASSET" CodexBarCLI codexbar VERSION)
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256"
|
||||
else
|
||||
shasum -a 256 "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256"
|
||||
fi
|
||||
|
||||
echo "out_dir=$OUT_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload release assets
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${RELEASE_TAG}"
|
||||
OUT_DIR="${{ steps.pkg.outputs.out_dir }}"
|
||||
ASSET="${{ steps.pkg.outputs.asset }}"
|
||||
gh release upload "$TAG" "$OUT_DIR/$ASSET" "$OUT_DIR/$ASSET.sha256" --clobber
|
||||
|
||||
- name: Upload workflow artifact (manual runs)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: codexbar-cli-${{ matrix.name }}
|
||||
path: |
|
||||
${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}
|
||||
${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}.sha256
|
||||
|
||||
update-homebrew-tap:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-cli
|
||||
if: github.event_name == 'release'
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: release
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${RELEASE_TAG}"
|
||||
if [[ -z "$tag" ]]; then
|
||||
echo "Missing release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$tag" =~ ^v[0-9A-Za-z._-]+$ ]]; then
|
||||
echo "Invalid release tag: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "request_id=codexbar-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Dispatch tap update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.release.outputs.tag }}
|
||||
REQUEST_ID: ${{ steps.release.outputs.request_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$GH_TOKEN"
|
||||
for attempt in {1..6}; do
|
||||
if gh workflow run update-formula.yml \
|
||||
--repo steipete/homebrew-tap \
|
||||
-f formula=codexbar \
|
||||
-f tag="$RELEASE_TAG" \
|
||||
-f repository=steipete/CodexBar \
|
||||
-f artifact_template='CodexBarCLI-{tag}-{target}.tar.gz' \
|
||||
-f target_aliases='darwin_arm64=macos-arm64,darwin_amd64=macos-x86_64,linux_arm64=linux-aarch64,linux_amd64=linux-x86_64' \
|
||||
-f cask=codexbar \
|
||||
-f cask_artifact='CodexBar-macos-universal-{version}.zip' \
|
||||
-f request_id="$REQUEST_ID"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" -eq 6 ]]; then
|
||||
echo "Failed to dispatch tap update after ${attempt} attempts." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
|
||||
- name: Wait for tap update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
REQUEST_ID: ${{ steps.release.outputs.request_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for _ in {1..20}; do
|
||||
run_id="$(
|
||||
gh run list \
|
||||
--repo steipete/homebrew-tap \
|
||||
--workflow update-formula.yml \
|
||||
--json databaseId,displayTitle \
|
||||
--jq '.[] | select(.displayTitle | contains(env.REQUEST_ID)) | .databaseId' 2>/tmp/codexbar-tap-run-list.err \
|
||||
| head -n1 || true
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
gh run watch "$run_id" --repo steipete/homebrew-tap --exit-status
|
||||
exit 0
|
||||
fi
|
||||
cat /tmp/codexbar-tap-run-list.err >&2 || true
|
||||
sleep 5
|
||||
done
|
||||
echo "Timed out waiting for tap workflow to appear." >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,188 @@
|
||||
name: Monitor Upstream Changes
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run Monday and Thursday at 9 AM UTC
|
||||
- cron: '0 9 * * 1,4'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: 'Which upstream to check'
|
||||
required: false
|
||||
default: 'all'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- upstream
|
||||
- quotio
|
||||
|
||||
jobs:
|
||||
check-upstreams:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
- name: Add upstream remotes
|
||||
run: |
|
||||
git remote add upstream https://github.com/steipete/CodexBar.git || true
|
||||
git remote add quotio https://github.com/nguyenphutrong/quotio.git || true
|
||||
git fetch upstream
|
||||
git fetch quotio
|
||||
|
||||
- name: Check for new commits
|
||||
id: check
|
||||
run: |
|
||||
remote_default_branch() {
|
||||
local remote="$1"
|
||||
local branch=""
|
||||
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
|
||||
if [ -z "$branch" ]; then
|
||||
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
|
||||
fi
|
||||
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
for candidate in main master; do
|
||||
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "Could not resolve default branch for ${remote}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
UPSTREAM_BRANCH=$(remote_default_branch upstream)
|
||||
QUOTIO_BRANCH=$(remote_default_branch quotio)
|
||||
UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}"
|
||||
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
|
||||
echo "upstream_ref=$UPSTREAM_REF" >> $GITHUB_OUTPUT
|
||||
echo "quotio_ref=$QUOTIO_REF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Count new commits in upstream
|
||||
UPSTREAM_NEW=$(git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "upstream_commits=$UPSTREAM_NEW" >> $GITHUB_OUTPUT
|
||||
|
||||
# Count new commits in quotio (last 7 days)
|
||||
QUOTIO_NEW=$(git log --oneline "$QUOTIO_REF" --since="7 days ago" 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "quotio_commits=$QUOTIO_NEW" >> $GITHUB_OUTPUT
|
||||
|
||||
# Get commit summaries
|
||||
echo "upstream_summary<<EOF" >> $GITHUB_OUTPUT
|
||||
git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "quotio_summary<<EOF" >> $GITHUB_OUTPUT
|
||||
git log --oneline "$QUOTIO_REF" --since="7 days ago" 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create or update issue
|
||||
if: steps.check.outputs.upstream_commits > 0 || steps.check.outputs.quotio_commits > 0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const upstreamCommits = '${{ steps.check.outputs.upstream_commits }}';
|
||||
const quotioCommits = '${{ steps.check.outputs.quotio_commits }}';
|
||||
const upstreamRef = '${{ steps.check.outputs.upstream_ref }}';
|
||||
const quotioRef = '${{ steps.check.outputs.quotio_ref }}';
|
||||
const upstreamBranch = upstreamRef.replace('upstream/', '');
|
||||
const quotioBranch = quotioRef.replace('quotio/', '');
|
||||
const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`;
|
||||
const quotioSummary = `${{ steps.check.outputs.quotio_summary }}`;
|
||||
|
||||
const body = `## 🔄 Upstream Changes Detected
|
||||
|
||||
**steipete/CodexBar:** ${upstreamCommits} new commits
|
||||
**quotio:** ${quotioCommits} new commits (last 7 days)
|
||||
**Source refs:** ${upstreamRef}, ${quotioRef}
|
||||
|
||||
### steipete/CodexBar Recent Commits
|
||||
\`\`\`
|
||||
${upstreamSummary}
|
||||
\`\`\`
|
||||
|
||||
### quotio Recent Commits
|
||||
\`\`\`
|
||||
${quotioSummary}
|
||||
\`\`\`
|
||||
|
||||
### 📋 Review Actions
|
||||
|
||||
**Review upstream changes:**
|
||||
\`\`\`bash
|
||||
./Scripts/review_upstream.sh upstream
|
||||
\`\`\`
|
||||
|
||||
**Review quotio changes:**
|
||||
\`\`\`bash
|
||||
./Scripts/analyze_quotio.sh
|
||||
\`\`\`
|
||||
|
||||
**View detailed diffs:**
|
||||
\`\`\`bash
|
||||
git diff main..${upstreamRef}
|
||||
git log -p ${quotioRef} --since='7 days ago'
|
||||
\`\`\`
|
||||
|
||||
### 🔗 Links
|
||||
- [steipete commits](https://github.com/steipete/CodexBar/compare/${context.sha}...steipete:CodexBar:${upstreamBranch})
|
||||
- [quotio commits](https://github.com/nguyenphutrong/quotio/commits/${quotioBranch})
|
||||
|
||||
---
|
||||
*Auto-generated by upstream-monitor workflow*
|
||||
*Last checked: ${new Date().toISOString()}*`;
|
||||
|
||||
// Check for existing open issue
|
||||
const issues = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'upstream-sync',
|
||||
state: 'open'
|
||||
});
|
||||
|
||||
if (issues.data.length > 0) {
|
||||
// Update existing issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issues.data[0].number,
|
||||
body: body
|
||||
});
|
||||
|
||||
// Add comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issues.data[0].number,
|
||||
body: `🔄 Updated with latest changes (${upstreamCommits} upstream, ${quotioCommits} quotio)`
|
||||
});
|
||||
} else {
|
||||
// Create new issue
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: '🔄 Upstream Changes Available for Review',
|
||||
body: body,
|
||||
labels: ['upstream-sync', 'needs-review']
|
||||
});
|
||||
}
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.check.outputs.upstream_commits == 0 && steps.check.outputs.quotio_commits == 0
|
||||
run: |
|
||||
echo "✅ No new upstream changes detected"
|
||||
echo "steipete/CodexBar: up to date"
|
||||
echo "quotio: no commits in last 7 days"
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Xcode user/session
|
||||
xcuserdata/
|
||||
.swiftpm/xcode/xcshareddata/
|
||||
.codexbar/config.json
|
||||
*.env
|
||||
*.local
|
||||
|
||||
# Build products
|
||||
.build/
|
||||
DerivedData
|
||||
|
||||
# Bundles / artifacts
|
||||
# Main app bundle (any variation)
|
||||
CodexBar.app/
|
||||
CodexBar *.app/
|
||||
CodexBar_*.app/
|
||||
Codexbar.app/
|
||||
# Release artifacts
|
||||
*.ipa
|
||||
*.dSYM*
|
||||
*.xcarchive/
|
||||
*.xcresult/
|
||||
*.zip
|
||||
*.delta
|
||||
*.dmg
|
||||
*.pkg
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
Icon.iconset
|
||||
debug_*.swift
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.codex/environments/
|
||||
.swiftpm-cache/
|
||||
.tmp-clang/
|
||||
__pycache__/
|
||||
|
||||
# Debug/analysis docs
|
||||
docs/*-analysis.md
|
||||
docs/.viewport-audit/
|
||||
docs/.astro/
|
||||
|
||||
# Swift Package Manager metadata (leave sources tracked)
|
||||
# Packages/
|
||||
# Package.resolved
|
||||
@@ -0,0 +1,51 @@
|
||||
# SwiftFormat configuration for CodexBar
|
||||
# Compatible with Swift 6 strict concurrency mode
|
||||
|
||||
# IMPORTANT: Don't remove self where it's required for Swift 6 concurrency
|
||||
--self insert # Insert self for member references (required for Swift 6)
|
||||
--selfrequired # List of functions that require explicit self
|
||||
--importgrouping testable-bottom # Group @testable imports at the bottom
|
||||
--extensionacl on-declarations # Set ACL on extension members
|
||||
|
||||
# Indentation
|
||||
--indent 4
|
||||
--indentcase false
|
||||
--ifdef no-indent
|
||||
--xcodeindentation enabled
|
||||
|
||||
# Line breaks
|
||||
--linebreaks lf
|
||||
--maxwidth 120
|
||||
|
||||
# Whitespace
|
||||
--trimwhitespace always
|
||||
--emptybraces no-space
|
||||
--nospaceoperators ...,..<
|
||||
--ranges no-space
|
||||
--someAny true
|
||||
|
||||
# Wrapping
|
||||
--wraparguments before-first
|
||||
--wrapparameters before-first
|
||||
--wrapcollections before-first
|
||||
--closingparen same-line
|
||||
|
||||
# Organization
|
||||
--organizetypes class,struct,enum,extension
|
||||
--extensionmark "MARK: - %t + %p"
|
||||
--marktypes always
|
||||
--markextensions always
|
||||
--structthreshold 0
|
||||
--enumthreshold 0
|
||||
|
||||
# Swift 6 specific
|
||||
--swiftversion 6.3
|
||||
--disable redundantSendable # Keep explicit concurrency contracts visible
|
||||
|
||||
# Other
|
||||
--stripunusedargs closure-only
|
||||
--header ignore
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Core/PeekabooCore/Sources/PeekabooCore/Extensions/NSArray+Extensions.swift
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# SwiftLint configuration for Peekaboo - Swift 6 compatible
|
||||
|
||||
# Paths to include
|
||||
included:
|
||||
- Sources
|
||||
- Tests
|
||||
|
||||
# Paths to exclude
|
||||
excluded:
|
||||
- .build
|
||||
- DerivedData
|
||||
- "**/Generated"
|
||||
- "**/Resources"
|
||||
- "**/.build"
|
||||
- "**/Package.swift"
|
||||
- "**/Tests/Resources"
|
||||
- "Apps/CLI/.build"
|
||||
- "**/DerivedData"
|
||||
- "**/.swiftpm"
|
||||
- Pods
|
||||
- Carthage
|
||||
- fastlane
|
||||
- vendor
|
||||
- "*.playground"
|
||||
# Exclude specific files that should not be linted/formatted
|
||||
- "Core/PeekabooCore/Sources/PeekabooCore/Extensions/NSArray+Extensions.swift"
|
||||
|
||||
# Analyzer rules (require compilation)
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
- unused_import
|
||||
|
||||
# Enable specific rules
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- contains_over_first_not_nil
|
||||
- empty_count
|
||||
- empty_string
|
||||
- explicit_init
|
||||
- fallthrough
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- pattern_matching_keywords
|
||||
- private_outlet
|
||||
- prohibited_super_call
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- vertical_parameter_alignment_on_call
|
||||
|
||||
# Disable rules that conflict with Swift 6 or our coding style
|
||||
disabled_rules:
|
||||
# Swift 6 requires explicit self - disable explicit_self rule
|
||||
- explicit_self
|
||||
|
||||
# SwiftFormat handles these
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- trailing_comma
|
||||
- vertical_whitespace
|
||||
- indentation_width
|
||||
|
||||
# Too restrictive or not applicable
|
||||
- identifier_name # Single letter names are fine in many contexts
|
||||
- file_header
|
||||
- explicit_top_level_acl
|
||||
- explicit_acl
|
||||
- explicit_type_interface
|
||||
- missing_docs
|
||||
- required_deinit
|
||||
- prefer_nimble
|
||||
- quick_discouraged_call
|
||||
- quick_discouraged_focused_test
|
||||
- quick_discouraged_pending_test
|
||||
- anonymous_argument_in_multiline_closure
|
||||
- no_extension_access_modifier
|
||||
- no_grouping_extension
|
||||
- switch_case_on_newline
|
||||
- strict_fileprivate
|
||||
- extension_access_modifier
|
||||
- convenience_type
|
||||
- no_magic_numbers
|
||||
- one_declaration_per_file
|
||||
- vertical_whitespace_between_cases
|
||||
- vertical_whitespace_closing_braces
|
||||
- superfluous_else
|
||||
- number_separator
|
||||
- prefixed_toplevel_constant
|
||||
- opening_brace
|
||||
- trailing_closure
|
||||
- contrasted_opening_brace
|
||||
- sorted_imports
|
||||
- redundant_type_annotation
|
||||
- shorthand_optional_binding
|
||||
- untyped_error_in_catch
|
||||
- file_name
|
||||
- todo
|
||||
|
||||
# Rule configurations
|
||||
force_cast: warning
|
||||
force_try: warning
|
||||
|
||||
# identifier_name rule disabled - see disabled_rules section
|
||||
|
||||
type_name:
|
||||
min_length:
|
||||
warning: 2
|
||||
error: 1
|
||||
max_length:
|
||||
warning: 60
|
||||
error: 80
|
||||
|
||||
function_body_length:
|
||||
warning: 150
|
||||
error: 300
|
||||
|
||||
file_length:
|
||||
warning: 1500
|
||||
error: 2500
|
||||
ignore_comment_only_lines: true
|
||||
|
||||
type_body_length:
|
||||
warning: 800
|
||||
error: 1200
|
||||
|
||||
cyclomatic_complexity:
|
||||
warning: 20
|
||||
error: 120
|
||||
|
||||
large_tuple:
|
||||
warning: 4
|
||||
error: 5
|
||||
|
||||
nesting:
|
||||
type_level:
|
||||
warning: 4
|
||||
error: 6
|
||||
function_level:
|
||||
warning: 5
|
||||
error: 7
|
||||
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 250
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
|
||||
# Custom rules can be added here if needed
|
||||
|
||||
# Reporter type
|
||||
reporter: "xcode"
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,48 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Modules
|
||||
- `Sources/CodexBar`: Swift 6 menu bar app (usage/credits probes, icon renderer, settings). Keep changes small and reuse existing helpers.
|
||||
- `Tests/CodexBarTests`: XCTest coverage for usage parsing, status probes, icon patterns; mirror new logic with focused tests.
|
||||
- `Scripts`: build/package helpers (`package_app.sh`, `sign-and-notarize.sh`, `make_appcast.sh`, `build_icon.sh`, `compile_and_run.sh`). Release wrappers call `Scripts/mac-release`, which resolves `MAC_RELEASE_TOOL` or the shared `agent-scripts` checkout.
|
||||
- `docs`: release notes and process (`docs/RELEASING.md`, screenshots). Root-level zips/appcast are generated artifacts—avoid editing except during releases.
|
||||
|
||||
## Build, Test, Run
|
||||
- Dev loop: `./Scripts/compile_and_run.sh` kills old instances, builds, packages, relaunches `CodexBar.app`, and confirms it stays running; add `--test` for the sharded full suite.
|
||||
- Quick build/test: `swift build` (debug) or `swift build -c release`; `make test` for the sharded full suite.
|
||||
- Package locally: `./Scripts/package_app.sh` to refresh `CodexBar.app`, then restart with `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`.
|
||||
- Release flow: `./Scripts/release.sh`; app metadata lives in `.mac-release.env`, repo build/signing stays in `Scripts/sign-and-notarize.sh`, and validation steps live in `docs/RELEASING.md`.
|
||||
|
||||
## Coding Style & Naming
|
||||
- Enforce SwiftFormat/SwiftLint: run `swiftformat Sources Tests` and `swiftlint --strict`. 4-space indent, 120-char lines, explicit `self` is intentional—do not remove.
|
||||
- Favor small, typed structs/enums; maintain existing `MARK` organization. Use descriptive symbols; match current commit tone.
|
||||
|
||||
## Testing Guidelines
|
||||
- Add/extend XCTest cases under `Tests/CodexBarTests/*Tests.swift` (`FeatureNameTests` with `test_caseDescription` methods).
|
||||
- Swift Testing: prefer backticked sentence names; no camelCase.
|
||||
- Model names in tests/code: released models or clearly fictitious names only; never expose unreleased names.
|
||||
- Always run `make test` before handoff; add focused `swift test --filter ...` runs for parser/provider fixes when possible.
|
||||
- After any code change, run `make check` and fix all reported format/lint issues before handoff.
|
||||
- Prefer CLI/focused tests over app-bundle live tests when behavior can be verified without relaunching CodexBar.
|
||||
- Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts. Live provider probes, browser-cookie imports, `codexbar usage` against real accounts, and real SecItem reads must be explicitly requested; otherwise use parser tests, stubs, test stores, or `KeychainNoUIQuery`.
|
||||
- macOS CI is brittle around headless AppKit status/menu tests. Prefer covering menu behavior through stable state/model seams (`MenuDescriptor`, `ProvidersPane`, `CodexAccountsSectionState`, etc.) instead of constructing live `NSStatusBar`/`NSMenu` flows unless the AppKit wiring itself is the thing under test.
|
||||
|
||||
## Commit & PR Guidelines
|
||||
- Commit messages: short imperative clauses (e.g., “Improve usage probe”, “Fix icon dimming”); keep commits scoped.
|
||||
- PRs/patches should list summary, commands run, screenshots/GIFs for UI changes, and linked issue/reference when relevant.
|
||||
|
||||
## Agent Notes
|
||||
- Use the provided scripts and package manager (SwiftPM); avoid adding dependencies or tooling without confirmation.
|
||||
- Menu bar automation: capture the target screen first and verify the CodexBar icon is visibly onscreen. Reject `click-extra` success when coordinates fall outside display bounds; hidden menu extras are not click proof.
|
||||
- Validate UI/runtime behavior against the freshly built bundle; restart via the pkill+open command above to avoid running stale binaries.
|
||||
- To guarantee the right bundle is running after a rebuild, use: `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`.
|
||||
- For CLI-testable provider/parser/settings behavior, use CLI/focused tests instead of `Scripts/package_app.sh` or `./Scripts/compile_and_run.sh`.
|
||||
- Run `./Scripts/compile_and_run.sh` only when UI/runtime behavior needs bundle-level validation; it builds, tests, packages, relaunches, and verifies the app stays running.
|
||||
- Widget/Tahoe UI issues: use Parallels macOS VM plus screenshots/clicks for autonomous verification.
|
||||
- Release script: keep it in the foreground; do not background it—wait until it finishes.
|
||||
- Sparkle release key: use `.mac-release.env` `MAC_RELEASE_SIGNING_KEY_FILE`, the legacy `AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=` key. Do not use `sparkle-private-key-KEEP-SECURE.txt`; that is VibeTunnel's mismatched key.
|
||||
- Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage.
|
||||
- Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`.
|
||||
- Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.).
|
||||
- Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.***
|
||||
- Claude CLI status line is custom + user-configurable; never rely on it for usage parsing.
|
||||
- Cookie imports: default Chrome-only when possible to avoid other browser prompts; override via browser list when needed.
|
||||
+1622
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 659 KiB |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "extended-srgb:0.00000,0.00000,0.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "codexbar.png",
|
||||
"name" : "codexbar",
|
||||
"position" : {
|
||||
"scale" : 1.0,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.2
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: build check docs-list format lint release restart start start-debug start-release stop test test-live test-tty
|
||||
|
||||
start:
|
||||
./Scripts/compile_and_run.sh
|
||||
|
||||
start-debug:
|
||||
./Scripts/compile_and_run.sh
|
||||
|
||||
start-release:
|
||||
./Scripts/package_app.sh release
|
||||
pkill -x CodexBar || pkill -f CodexBar.app || true
|
||||
cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app
|
||||
|
||||
restart: start
|
||||
|
||||
stop:
|
||||
pkill -x CodexBar || pkill -f CodexBar.app || true
|
||||
|
||||
check lint:
|
||||
./Scripts/lint.sh lint
|
||||
|
||||
format:
|
||||
./Scripts/lint.sh format
|
||||
|
||||
docs-list:
|
||||
node Scripts/docs-list.mjs
|
||||
|
||||
build:
|
||||
swift build
|
||||
|
||||
test:
|
||||
./Scripts/test.sh
|
||||
|
||||
test-tty:
|
||||
swift test --filter TTYIntegrationTests
|
||||
|
||||
test-live:
|
||||
LIVE_TEST=1 swift test --filter LiveAccountTests
|
||||
|
||||
release:
|
||||
./Scripts/package_app.sh release
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"originHash" : "d5ef2ec180d58ea5f869b40e5024f2e23d1ce02e999305b2e78d1b5c3783b83b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander",
|
||||
"state" : {
|
||||
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
|
||||
"version" : "0.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "keyboardshortcuts",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sindresorhus/KeyboardShortcuts",
|
||||
"state" : {
|
||||
"revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
|
||||
"version" : "2.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
|
||||
"version" : "2.9.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sweetcookiekit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/SweetCookieKit",
|
||||
"state" : {
|
||||
"revision" : "21bedea672a3e63ccad24d744051e76cdf0462dd",
|
||||
"version" : "0.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
|
||||
"version" : "1.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto",
|
||||
"state" : {
|
||||
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
|
||||
"version" : "3.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log",
|
||||
"state" : {
|
||||
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
|
||||
"version" : "1.13.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "vortex",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zats/Vortex",
|
||||
"state" : {
|
||||
"revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
// swift-tools-version: 6.2
|
||||
import Foundation
|
||||
import PackageDescription
|
||||
|
||||
let sweetCookieKitPath = "../SweetCookieKit"
|
||||
let useLocalSweetCookieKit =
|
||||
ProcessInfo.processInfo.environment["CODEXBAR_USE_LOCAL_SWEETCOOKIEKIT"] == "1"
|
||||
let sweetCookieKitDependency: Package.Dependency =
|
||||
useLocalSweetCookieKit && FileManager.default.fileExists(atPath: sweetCookieKitPath)
|
||||
? .package(path: sweetCookieKitPath)
|
||||
: .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.4.1")
|
||||
|
||||
let sqlite3LibDir = ProcessInfo.processInfo.environment["CODEXBAR_SQLITE3_LIB_DIR"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let sqlite3LinkerSettings: [LinkerSetting] = if let sqlite3LibDir, !sqlite3LibDir.isEmpty {
|
||||
[.unsafeFlags(["-L\(sqlite3LibDir)"], .when(platforms: [.linux]))]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
|
||||
let package = Package(
|
||||
name: "CodexBar",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: {
|
||||
var products: [Product] = [
|
||||
.library(name: "CodexBarCore", targets: ["CodexBarCore"]),
|
||||
.executable(name: "CodexBarCLI", targets: ["CodexBarCLI"]),
|
||||
// Offline adaptive-refresh replay harness. Keep the supporting library package-internal.
|
||||
.executable(name: "AdaptiveReplayCLI", targets: ["AdaptiveReplayCLI"]),
|
||||
]
|
||||
|
||||
#if os(macOS)
|
||||
products.append(contentsOf: [
|
||||
.executable(name: "CodexBar", targets: ["CodexBar"]),
|
||||
.executable(name: "CodexBarClaudeWatchdog", targets: ["CodexBarClaudeWatchdog"]),
|
||||
.executable(name: "CodexBarWidget", targets: ["CodexBarWidget"]),
|
||||
.executable(name: "CodexBarClaudeWebProbe", targets: ["CodexBarClaudeWebProbe"]),
|
||||
])
|
||||
#endif
|
||||
|
||||
return products
|
||||
}(),
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.3"),
|
||||
.package(url: "https://github.com/steipete/Commander", from: "0.2.1"),
|
||||
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-log", from: "1.13.2"),
|
||||
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"),
|
||||
.package(url: "https://github.com/zats/Vortex", revision: "ef5392088d4aeb255c4eee83157dbdafcd31bf07"),
|
||||
sweetCookieKitDependency,
|
||||
],
|
||||
targets: {
|
||||
var targets: [Target] = [
|
||||
// Host pkg-config paths contaminate cross-musl links; the module map supplies sqlite3 linkage.
|
||||
.systemLibrary(
|
||||
name: "CSQLite3",
|
||||
providers: [
|
||||
.apt(["libsqlite3-dev"]),
|
||||
.brew(["sqlite3"]),
|
||||
]),
|
||||
.target(
|
||||
name: "CodexBarCore",
|
||||
dependencies: [
|
||||
.target(name: "CSQLite3", condition: .when(platforms: [.linux])),
|
||||
.product(name: "Crypto", package: "swift-crypto"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "SweetCookieKit", package: "SweetCookieKit"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
],
|
||||
linkerSettings: sqlite3LinkerSettings),
|
||||
.executableTarget(
|
||||
name: "CodexBarCLI",
|
||||
dependencies: [
|
||||
"CodexBarCore",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
path: "Sources/CodexBarCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
],
|
||||
linkerSettings: sqlite3LinkerSettings),
|
||||
// Sole owner of the adaptive refresh decision table. Package-internal so the app and
|
||||
// offline replay tool share behavior without publishing another library product.
|
||||
.target(
|
||||
name: "AdaptiveRefreshCore",
|
||||
dependencies: [],
|
||||
path: "Sources/AdaptiveRefreshCore",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
// Offline adaptive-refresh replay harness: pure Foundation,
|
||||
// no CodexBar/CodexBarCore dependency, so it builds anywhere CodexBarCore does.
|
||||
.target(
|
||||
name: "AdaptiveReplayKit",
|
||||
dependencies: ["AdaptiveRefreshCore"],
|
||||
path: "Sources/AdaptiveReplayKit",
|
||||
exclude: ["README.md"],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "AdaptiveReplayCLI",
|
||||
dependencies: ["AdaptiveReplayKit"],
|
||||
path: "Sources/AdaptiveReplayCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "AdaptiveReplayCLITests",
|
||||
dependencies: ["AdaptiveReplayCLI", "AdaptiveReplayKit"],
|
||||
path: "Tests/AdaptiveReplayCLITests",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "AdaptiveReplayKitTests",
|
||||
dependencies: ["AdaptiveRefreshCore", "AdaptiveReplayKit"],
|
||||
path: "Tests/AdaptiveReplayKitTests",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "CodexBarLinuxTests",
|
||||
dependencies: [
|
||||
"CodexBarCore",
|
||||
"CodexBarCLI",
|
||||
.target(name: "CSQLite3", condition: .when(platforms: [.linux])),
|
||||
],
|
||||
path: "TestsLinux",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
]
|
||||
|
||||
#if os(macOS)
|
||||
targets.append(contentsOf: [
|
||||
.executableTarget(
|
||||
name: "CodexBarClaudeWatchdog",
|
||||
dependencies: [],
|
||||
path: "Sources/CodexBarClaudeWatchdog",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "CodexBar",
|
||||
dependencies: [
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"),
|
||||
.product(name: "Vortex", package: "Vortex"),
|
||||
"AdaptiveRefreshCore",
|
||||
"CodexBarCore",
|
||||
],
|
||||
path: "Sources/CodexBar",
|
||||
resources: [
|
||||
.process("Resources"),
|
||||
],
|
||||
swiftSettings: [
|
||||
// Opt into Swift 6 strict concurrency (approachable migration path).
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.define("ENABLE_SPARKLE"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "CodexBarWidget",
|
||||
dependencies: ["CodexBarCore"],
|
||||
path: "Sources/CodexBarWidget",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "CodexBarClaudeWebProbe",
|
||||
dependencies: ["CodexBarCore"],
|
||||
path: "Sources/CodexBarClaudeWebProbe",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
])
|
||||
|
||||
targets.append(.testTarget(
|
||||
name: "CodexBarTests",
|
||||
dependencies: ["CodexBar", "CodexBarCore", "CodexBarCLI", "CodexBarWidget"],
|
||||
path: "Tests",
|
||||
exclude: ["AdaptiveReplayCLITests", "AdaptiveReplayKitTests"],
|
||||
resources: [
|
||||
.copy("CodexBarTests/Fixtures"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]))
|
||||
#endif
|
||||
|
||||
return targets
|
||||
}())
|
||||
@@ -0,0 +1,254 @@
|
||||
# CodexBar 🎚️ — May your tokens never run out.
|
||||
|
||||
> Every AI coding limit, in your menu bar.
|
||||
|
||||
[](https://github.com/steipete/CodexBar/releases/latest)
|
||||
[](https://github.com/steipete/CodexBar/releases/latest)
|
||||
[](https://github.com/steipete/homebrew-tap)
|
||||
[](https://aur.archlinux.org/packages/codexbar-cli)
|
||||
[](LICENSE)
|
||||
[](https://codexbar.app)
|
||||
|
||||
<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 59 providers." width="100%" /></a>
|
||||
|
||||
Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons.
|
||||
|
||||
<img src="docs/codexbar.png" alt="CodexBar menu popover with provider tiles, usage bars, and reset countdowns" width="520" />
|
||||
|
||||
## Why
|
||||
|
||||
- **Plan around resets.** Per-provider session, weekly, and monthly windows with countdowns to the next reset — stop guessing whether to start that long task.
|
||||
- **Credits, spend, and cost scans.** Credit balances, Admin API spend dashboards, provider billing summaries, and local cost scans where the source exposes enough detail.
|
||||
- **Live status.** Provider status polling surfaces incident badges in the menu and an indicator overlay on the bar icon.
|
||||
- **Privacy-first.** Reuses existing provider sessions — OAuth, device flow, API keys, browser cookies, local files — so no passwords are stored.
|
||||
|
||||
## Install
|
||||
|
||||
### Requirements
|
||||
- macOS 14+ (Sonoma)
|
||||
|
||||
### GitHub Releases
|
||||
Download: <https://github.com/steipete/CodexBar/releases>
|
||||
|
||||
### Homebrew
|
||||
```bash
|
||||
brew install --cask codexbar
|
||||
```
|
||||
|
||||
### CLI Tarballs (macOS/Linux)
|
||||
Homebrew formula (Linux today):
|
||||
```bash
|
||||
brew install steipete/tap/codexbar
|
||||
```
|
||||
Arch Linux AUR package:
|
||||
```bash
|
||||
yay -S codexbar-cli
|
||||
```
|
||||
Or download release tarballs from GitHub Releases:
|
||||
- macOS: `CodexBarCLI-v<tag>-macos-arm64.tar.gz`, `CodexBarCLI-v<tag>-macos-x86_64.tar.gz`
|
||||
- Linux (glibc): `CodexBarCLI-v<tag>-linux-aarch64.tar.gz`, `CodexBarCLI-v<tag>-linux-x86_64.tar.gz`
|
||||
- Linux (static musl): `CodexBarCLI-v<tag>-linux-musl-aarch64.tar.gz`, `CodexBarCLI-v<tag>-linux-musl-x86_64.tar.gz`
|
||||
|
||||
### First run
|
||||
- Open Settings → Providers and enable what you use.
|
||||
- Install/sign in to the provider sources you rely on: CLIs, browser sessions, OAuth/device flow, API keys, local app files, or provider apps depending on the provider.
|
||||
- Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras.
|
||||
|
||||
### Set API keys from the CLI
|
||||
Provider toggles and API keys live in the resolved CodexBar config file. New installs use
|
||||
`~/.config/codexbar/config.json`; existing `~/.codexbar/config.json` installs still load from the legacy path. You can
|
||||
script the same provider list that Settings → Providers uses:
|
||||
|
||||
```bash
|
||||
codexbar config providers
|
||||
codexbar config enable --provider grok
|
||||
codexbar config disable --provider cursor
|
||||
```
|
||||
|
||||
For API-key providers, store a key without opening Settings:
|
||||
|
||||
```bash
|
||||
printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin
|
||||
```
|
||||
|
||||
`set-api-key` trims the piped value, stores it with restrictive config-file permissions, and enables the provider by default. Use `--no-enable` to only save the key, or `--api-key <key>` for one-off local scripts where shell history is not a concern.
|
||||
See [CLI configuration](docs/cli-configuration.md) for the full flow.
|
||||
|
||||
## Providers
|
||||
|
||||
- [Codex](docs/codex.md) — OAuth API or local Codex CLI, plus optional OpenAI web dashboard extras.
|
||||
- [OpenAI](docs/openai.md) — Admin API key usage/cost graphs with legacy credit-balance fallback.
|
||||
- [Azure OpenAI](docs/azure-openai.md) — API key, endpoint, and deployment validation probe.
|
||||
- [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available.
|
||||
- [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets.
|
||||
- [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage.
|
||||
- [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows.
|
||||
- [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas.
|
||||
- [Alibaba Token Plan](docs/alibaba-token-plan.md) — Bailian browser/manual cookies for token-plan credits.
|
||||
- [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies).
|
||||
- [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth.
|
||||
- [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing.
|
||||
- [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API.
|
||||
- [Devin](docs/devin.md) — Chrome localStorage session or manual Bearer token for daily and weekly quotas.
|
||||
- [z.ai](docs/zai.md) — API token for personal/team quota, MCP, 5-hour, and hourly usage windows.
|
||||
- [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking.
|
||||
- [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage.
|
||||
- [T3 Chat](docs/t3chat.md) — Browser cookies capture for Base and Overage usage buckets.
|
||||
- [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit.
|
||||
- [Kimi K2 (unofficial)](docs/kimi-k2.md) — Legacy API key flow for credit-based usage totals.
|
||||
- [Kilo](docs/kilo.md) — API token with CLI-auth fallback for Kilo Pass usage.
|
||||
- [Kiro](docs/kiro.md) — CLI-based usage; monthly credits + bonus credits.
|
||||
- [Vertex AI](docs/vertexai.md) — Google Cloud gcloud OAuth with token cost tracking from local Claude logs.
|
||||
- [Augment](docs/augment.md) — Augment CLI or browser cookies for credits tracking and usage monitoring.
|
||||
- [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking.
|
||||
- [Ollama](docs/ollama.md) — API key access plus browser cookies for Ollama Cloud usage windows.
|
||||
- [Synthetic](docs/synthetic.md) — API key quota endpoint for rolling five-hour, weekly token, and search-hourly usage.
|
||||
- [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking.
|
||||
- [Warp](docs/warp.md) — API token for GraphQL request limits and monthly credits.
|
||||
- [ElevenLabs](docs/elevenlabs.md) — API key for character credits and voice slot usage.
|
||||
- [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers.
|
||||
- [CrossModel](docs/crossmodel.md) — API key wallet balance with daily, weekly, and monthly spend.
|
||||
- [Windsurf](docs/windsurf.md) — Browser localStorage session import or local SQLite cache for plan usage.
|
||||
- [Zed](docs/zed.md) — Zed editor Keychain session for plan, edit-prediction quota, billing cycle, and overdue invoices.
|
||||
- [Perplexity](docs/perplexity.md) — Account usage credits from Perplexity usage data.
|
||||
- [Xiaomi MiMo](docs/mimo.md) — Browser cookies for balance and token-plan usage.
|
||||
- [Doubao](docs/doubao.md) — API key for Volcengine Ark request-limit probes.
|
||||
- [Sakana AI](docs/sakana.md) — Manual Cookie header for 5-hour and weekly quota windows.
|
||||
- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
|
||||
- [Mistral](docs/mistral.md) — Browser cookies for API spend, credit balance, and monthly-plan usage.
|
||||
- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown).
|
||||
- [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking.
|
||||
- [Venice](docs/venice.md) — API key for DIEM or USD balance tracking.
|
||||
- [Codebuff](docs/codebuff.md) — API token (or `~/.config/manicode/credentials.json`) for credit balance + weekly rate limit.
|
||||
- [Crof](docs/crof.md) — API key for dollar credit balance and request quota tracking.
|
||||
- [Command Code](docs/command-code.md) — Browser or manual cookies for monthly USD credits from Command Code billing.
|
||||
- [Qoder](docs/qoder.md) — Browser or manual cookies for Qoder big model credit usage.
|
||||
- [StepFun](docs/stepfun.md) — Username + password login for Step Plan rate limits (5‑hour + weekly windows) and subscription plan name.
|
||||
- [AWS Bedrock](docs/bedrock.md) — AWS access keys or a named AWS profile (SSO/assume-role via the AWS CLI) for Cost Explorer spend, monthly budgets, and optional CloudWatch Claude activity.
|
||||
- [Grok](docs/grok.md) — Grok CLI billing RPC plus grok.com browser-session fallback.
|
||||
- [GroqCloud](docs/groqcloud.md) — API key for Enterprise Prometheus request/token/cache-hit metrics.
|
||||
- [LLM Proxy](docs/llm-proxy.md) — API key + base URL for aggregate proxy quota stats and provider breakdowns.
|
||||
- [ClawRouter](docs/clawrouter.md) — API key for monthly budget, spend, requests, tokens, and routed-provider usage.
|
||||
- [sub2api](docs/sub2api.md) — Self-hosted gateway key quota, subscription limits, wallet balance, and per-key usage.
|
||||
- [Wayfinder](docs/wayfinder.md) — Local router gateway polling for health, per-route breakdown, savings, and decision latency.
|
||||
- [LiteLLM](docs/litellm.md) — Virtual key + proxy URL for personal and team budget/spend tracking.
|
||||
- [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics.
|
||||
- [Poe](docs/poe.md) — API key for current point balance and recent points history.
|
||||
- [Chutes](docs/chutes.md) — API key for subscription usage, rolling and monthly quota windows, and pay-as-you-go quotas.
|
||||
- Open to new providers: [provider authoring guide](docs/provider.md).
|
||||
|
||||
## Icon & Screenshot
|
||||
The menu bar icon is a tiny usage meter. Bar meaning is provider-specific, and errors/stale data can dim the icon or
|
||||
show an incident indicator.
|
||||
|
||||
## Features
|
||||
- Multi-provider menu bar with per-provider toggles (Settings → Providers).
|
||||
- Provider-specific usage meters with reset countdowns.
|
||||
- Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history).
|
||||
- Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock.
|
||||
- Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories.
|
||||
- Provider status polling with incident badges in the menu and icon overlay.
|
||||
- Merge Icons mode to combine providers into one status item + switcher.
|
||||
- Display controls for provider icons, labels, bars, reset-time style, and highest-usage auto-selection.
|
||||
- Refresh cadence presets (manual, 1m, 2m, 5m, 15m).
|
||||
- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex`, `claude`, or `both` for local cost usage); macOS and Linux CLI builds available.
|
||||
- WidgetKit widgets for supported providers.
|
||||
- Localized app and website with a shared 21-language catalog, automatic website detection, persistent pickers, and RTL support.
|
||||
- Optional session quota notifications and weekly-reset confetti.
|
||||
- Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored).
|
||||
|
||||
## Privacy note
|
||||
Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, provider config files, local JSONL logs) when the related features are enabled. Provider tokens and token-account settings live in the CodexBar config file with restrictive file permissions. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12).
|
||||
|
||||
## macOS permissions (why they’re needed)
|
||||
- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers. If you don’t grant it, use another supported browser, manual cookies/API keys, OAuth, or CLI/local sources where that provider supports them.
|
||||
- **Keychain access (prompted by macOS)**:
|
||||
- Chromium cookie import needs the browser “Safe Storage” key to decrypt cookies.
|
||||
- Claude OAuth bootstrap may read the Claude CLI Keychain item when CodexBar has no usable cached credentials.
|
||||
- CodexBar may use Keychain for browser cookie decryption, cached cookie headers, and OAuth/device-flow credentials where those sources require it.
|
||||
- **How do I prevent those keychain alerts?**
|
||||
- Open **Keychain Access.app** → login keychain → search the prompted item (for Claude OAuth, usually “Claude Code-credentials”).
|
||||
- Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”.
|
||||
- Prefer adding just CodexBar (avoid “Allow all applications” unless you want it wide open).
|
||||
- Relaunch CodexBar after saving.
|
||||
- Reference screenshot: 
|
||||
- **How to do the same for the browser?**
|
||||
- Find the browser’s “Safe Storage” key (e.g., “Chrome Safe Storage”, “Brave Safe Storage”, “Microsoft Edge Safe Storage”).
|
||||
- Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”.
|
||||
- This removes the prompt when CodexBar decrypts cookies for that browser.
|
||||
- **Last resort — stop all Keychain reads entirely**: if "Always Allow" doesn't stick (e.g., macOS resets the ACL after a Chromium update or a `partition_id` reset), open **CodexBar → Settings → Advanced → Keychain access** and enable **Disable Keychain access**. CodexBar will no longer touch the Keychain. Browser-cookie-based providers will be skipped, but Claude/Codex OAuth via the CLI still works (it reads `~/.codex` / `~/.claude` config files, not the Keychain).
|
||||
- **Prompt after uninstall?** Deleting the app prevents a new launch from that bundle, but an already-running CodexBar process can keep requesting Keychain access until it quits. Check for that process, a Login Item, another installed copy, or a prompt that names a different requesting binary/path. See [Keychain prompt troubleshooting](docs/keychain-prompts.md) for safe checks and what to include in a support report without sharing secrets.
|
||||
- **Files & Folders prompts (folder/volume access)**: CodexBar launches provider CLIs and local probes for some providers. If those helpers read a project directory or external drive, macOS may ask CodexBar for that folder/volume (e.g., Desktop or an external volume). This is driven by the helper’s working directory, not background disk scanning.
|
||||
- **What we do not request in the background**: no Screen Recording or Accessibility permissions; user-triggered helper actions may ask macOS for Automation permission to open Terminal. No passwords are stored (browser cookies are reused when you opt in).
|
||||
|
||||
## Docs
|
||||
- Providers overview: [docs/providers.md](docs/providers.md)
|
||||
- Provider authoring: [docs/provider.md](docs/provider.md)
|
||||
- Issue labeling guide: [docs/ISSUE_LABELING.md](docs/ISSUE_LABELING.md)
|
||||
- UI & icon notes: [docs/ui.md](docs/ui.md)
|
||||
- CLI reference: [docs/cli.md](docs/cli.md)
|
||||
- Configuration: [docs/configuration.md](docs/configuration.md)
|
||||
- Keychain prompts: [docs/keychain-prompts.md](docs/keychain-prompts.md)
|
||||
- CLI configuration: [docs/cli-configuration.md](docs/cli-configuration.md)
|
||||
- Widgets: [docs/widgets.md](docs/widgets.md)
|
||||
- Architecture: [docs/architecture.md](docs/architecture.md)
|
||||
- Refresh loop: [docs/refresh-loop.md](docs/refresh-loop.md)
|
||||
- Status polling: [docs/status.md](docs/status.md)
|
||||
- Sparkle updates: [docs/sparkle.md](docs/sparkle.md)
|
||||
- Packaging: [docs/packaging.md](docs/packaging.md)
|
||||
- Development: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
||||
- Release checklist: [docs/RELEASING.md](docs/RELEASING.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Getting started (dev)
|
||||
- Clone the repo and open it in Xcode or run the scripts directly.
|
||||
- Launch once, then toggle providers in Settings → Providers.
|
||||
- Install/sign in to provider sources you rely on (CLIs, browser cookies, OAuth/device flow, API keys, or local app/config files).
|
||||
- Optional: set OpenAI cookies (Automatic or Manual) for Codex dashboard extras.
|
||||
|
||||
## Build from source
|
||||
Requires macOS 14+ and Swift 6.2+.
|
||||
|
||||
```bash
|
||||
./Scripts/package_app.sh # builds CodexBar.app in-place with ad-hoc signing
|
||||
open CodexBar.app
|
||||
```
|
||||
|
||||
Dev loop:
|
||||
```bash
|
||||
./Scripts/compile_and_run.sh
|
||||
./Scripts/compile_and_run.sh --test # also run the sharded test suite before packaging/relaunching
|
||||
make check # SwiftFormat + SwiftLint
|
||||
make docs-list # list docs with frontmatter summaries
|
||||
```
|
||||
|
||||
CLI install:
|
||||
```bash
|
||||
# after installing CodexBar.app in /Applications
|
||||
./bin/install-codexbar-cli.sh
|
||||
```
|
||||
|
||||
## Related
|
||||
- ✂️ [Trimmy](https://github.com/steipete/Trimmy) — “Paste once, run once.” Flatten multi-line shell snippets so they paste and run.
|
||||
- 🧳 [MCPorter](https://mcporter.dev) — TypeScript toolkit + CLI for Model Context Protocol servers.
|
||||
- 🧿 [oracle](https://askoracle.dev) — Ask the oracle when you're stuck. Invoke GPT-5 Pro with a custom context and files.
|
||||
|
||||
## Looking for a Windows version?
|
||||
- [Win-CodexBar](https://github.com/Finesssee/Win-CodexBar)
|
||||
|
||||
## Linux desktop integration?
|
||||
- [codexbar-waybar](https://github.com/Marouan-chak/codexbar-waybar) — Waybar custom module + GTK4 popover for Hyprland / Sway / other Wayland compositors, built on top of the bundled Linux CLI.
|
||||
- [Codexbar GNOME](https://extensions.gnome.org/extension/9841/codexbar/) — GNOME Shell extension that brings CodexBar usage into the desktop panel.
|
||||
- [codexbar-cinnamon-applet](https://github.com/jacobcalvert/codexbar-cinnamon-applet) — Linux Mint Cinnamon panel applet powered by CodexBar's JSON output.
|
||||
- [noctalia-codex-usage](https://github.com/rayoplateado/noctalia-codex-usage) — Noctalia/Quickshell plugin that shows Codex 5-hour and weekly usage limits, built on top of the bundled Linux CLI.
|
||||
- [KodexBar](https://github.com/tylxr59/KodexBar) — KDE Plasma widget that shows CodexBar usage in the Plasma panel, built on top of the bundled Linux CLI.
|
||||
- [codexbar-plasmoid](https://github.com/psimaker/codexbar-plasmoid) — KDE Plasma 6 widget for CodexBar's meter icon, provider switcher, quota windows, pace, credits, local cost, and status, powered by the bundled Linux CLI.
|
||||
|
||||
## Status bar & terminal integration
|
||||
- [showy-quota](https://github.com/enieuwy/showy-quota) — always-on AI plan quota strips for SketchyBar, tmux, and Zellij (standalone WASM plugin), built on `codexbar serve` / the bundled CLI.
|
||||
|
||||
## Credits
|
||||
Inspired by [ccusage](https://github.com/ryoppippi/ccusage) (MIT), specifically the cost usage tracking.
|
||||
|
||||
## License
|
||||
MIT • Peter Steinberger ([steipete](https://twitter.com/steipete))
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`steipete/CodexBar`
|
||||
- 原始仓库:https://github.com/steipete/CodexBar
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# Analyze quotio repository for interesting patterns and features
|
||||
# Usage: ./Scripts/analyze_quotio.sh [feature-area]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AREA=${1:-all}
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}==> Fetching latest quotio...${NC}"
|
||||
git fetch quotio 2>/dev/null || {
|
||||
echo -e "${YELLOW}Adding quotio remote...${NC}"
|
||||
git remote add quotio https://github.com/nguyenphutrong/quotio.git
|
||||
git fetch quotio
|
||||
}
|
||||
remote_default_branch() {
|
||||
local remote=$1
|
||||
local branch=""
|
||||
local candidate
|
||||
|
||||
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
|
||||
if [ -z "$branch" ]; then
|
||||
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
|
||||
fi
|
||||
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in main master; do
|
||||
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
QUOTIO_BRANCH=$(remote_default_branch quotio)
|
||||
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> Quotio Repository Analysis (${QUOTIO_REF})${NC}"
|
||||
echo ""
|
||||
|
||||
# Show recent activity
|
||||
echo -e "${BLUE}Recent Activity (last 30 days):${NC}"
|
||||
git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true
|
||||
echo ""
|
||||
|
||||
# Analyze file structure
|
||||
echo -e "${BLUE}File Structure:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -30 || true
|
||||
echo ""
|
||||
|
||||
# Find interesting patterns based on area
|
||||
case $AREA in
|
||||
"providers"|"all")
|
||||
echo -e "${BLUE}Provider Implementations:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -i provider | head -20 || true
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
||||
case $AREA in
|
||||
"ui"|"all")
|
||||
echo -e "${BLUE}UI Components:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(view|ui|menu)' | head -20 || true
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
||||
case $AREA in
|
||||
"auth"|"all")
|
||||
echo -e "${BLUE}Authentication/Session:${NC}"
|
||||
git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(auth|session|cookie|login)' | head -20 || true
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
||||
# Show commit messages for pattern analysis
|
||||
echo -e "${BLUE}Recent Commit Messages (for pattern analysis):${NC}"
|
||||
git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true
|
||||
echo ""
|
||||
|
||||
# Create analysis report
|
||||
REPORT_FILE="quotio-analysis-$(date +%Y%m%d).md"
|
||||
cat > "$REPORT_FILE" << EOF
|
||||
# Quotio Analysis Report
|
||||
**Date:** $(date +%Y-%m-%d)
|
||||
**Purpose:** Identify patterns and features for CodexBar fork inspiration
|
||||
**Source ref:** \`$QUOTIO_REF\`
|
||||
|
||||
## Recent Activity
|
||||
\`\`\`
|
||||
$(git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true)
|
||||
\`\`\`
|
||||
|
||||
## File Structure
|
||||
\`\`\`
|
||||
$(git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -50 || true)
|
||||
\`\`\`
|
||||
|
||||
## Recent Commits
|
||||
\`\`\`
|
||||
$(git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true)
|
||||
\`\`\`
|
||||
|
||||
## Areas of Interest
|
||||
|
||||
### Providers
|
||||
- [ ] Review provider implementations
|
||||
- [ ] Compare with CodexBar approach
|
||||
- [ ] Identify improvements
|
||||
|
||||
### UI/UX
|
||||
- [ ] Menu bar organization
|
||||
- [ ] Settings layout
|
||||
- [ ] Status indicators
|
||||
|
||||
### Authentication
|
||||
- [ ] Session management
|
||||
- [ ] Cookie handling
|
||||
- [ ] OAuth flows
|
||||
|
||||
### Multi-Account
|
||||
- [ ] Account switching
|
||||
- [ ] Account storage
|
||||
- [ ] UI patterns
|
||||
|
||||
## Action Items
|
||||
- [ ] Review specific files of interest
|
||||
- [ ] Document patterns (not code)
|
||||
- [ ] Create implementation plan
|
||||
- [ ] Implement independently
|
||||
|
||||
## Notes
|
||||
Remember: We're looking for PATTERNS and IDEAS, not copying code.
|
||||
All implementations must be original and follow CodexBar conventions.
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}Analysis report saved to: $REPORT_FILE${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. View specific files:"
|
||||
echo " ${GREEN}git show $QUOTIO_REF:path/to/file${NC}"
|
||||
echo ""
|
||||
echo "2. Compare implementations:"
|
||||
echo " ${GREEN}git diff main $QUOTIO_REF -- path/to/similar/file${NC}"
|
||||
echo ""
|
||||
echo "3. Review commit details:"
|
||||
echo " ${GREEN}git log -p $QUOTIO_REF --since='30 days ago'${NC}"
|
||||
echo ""
|
||||
echo "4. Document patterns in:"
|
||||
echo " ${GREEN}docs/QUOTIO_ANALYSIS.md${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Remember: Adapt patterns, don't copy code!${NC}"
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
npx --yes tailwindcss@3.4.19 \
|
||||
--config Scripts/tailwind.site.config.cjs \
|
||||
--input Scripts/site-tailwind.input.css \
|
||||
--output docs/site-utilities.css \
|
||||
--minify
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ICON_FILE=${1:-Icon.icon}
|
||||
BASENAME=${2:-Icon}
|
||||
OUT_ROOT=${3:-build/icon}
|
||||
XCODE_APP=${XCODE_APP:-/Applications/Xcode.app}
|
||||
|
||||
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool"
|
||||
if [[ ! -x "$ICTOOL" ]]; then
|
||||
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool"
|
||||
fi
|
||||
if [[ ! -x "$ICTOOL" ]]; then
|
||||
echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset"
|
||||
TMP_DIR="$OUT_ROOT/tmp"
|
||||
mkdir -p "$ICONSET_DIR" "$TMP_DIR"
|
||||
|
||||
MASTER_ART="$TMP_DIR/icon_art_824.png"
|
||||
MASTER_1024="$TMP_DIR/icon_1024.png"
|
||||
|
||||
# Render inner art (no margin) with macOS Default appearance
|
||||
"$ICTOOL" "$ICON_FILE" \
|
||||
--export-preview macOS Default 824 824 1 -45 "$MASTER_ART"
|
||||
|
||||
# Pad to 1024x1024 with transparent border
|
||||
sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null
|
||||
|
||||
# Generate required sizes
|
||||
sizes=(16 32 64 128 256 512 1024)
|
||||
for sz in "${sizes[@]}"; do
|
||||
out="$ICONSET_DIR/icon_${sz}x${sz}.png"
|
||||
sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null
|
||||
if [[ "$sz" -ne 1024 ]]; then
|
||||
dbl=$((sz*2))
|
||||
out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png"
|
||||
sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
# 512x512@2x already covered by 1024; ensure it exists
|
||||
cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png"
|
||||
|
||||
iconutil -c icns "$ICONSET_DIR" -o Icon.icns
|
||||
|
||||
echo "Icon.icns generated at $(pwd)/Icon.icns"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" changelog-html "$@"
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const resources = path.join(repoRoot, "Sources/CodexBar/Resources");
|
||||
const english = readCatalog("en");
|
||||
const englishKeys = Object.keys(english).sort();
|
||||
const strictLocales = ["ar", "ca", "fa", "th"];
|
||||
const languageKeys = ["language_arabic", "language_persian", "language_thai"];
|
||||
const isTest = process.argv.includes("--test");
|
||||
|
||||
function readCatalog(locale) {
|
||||
const file = path.join(resources, `${locale}.lproj/Localizable.strings`);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const output = execFileSync("plutil", ["-convert", "json", "-o", "-", file], { encoding: "utf8" });
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function tokenSignature(value) {
|
||||
// Exclude explicit `%%`, which does not consume an argument.
|
||||
const withoutEscapedPercents = value.replace(/%%/g, "");
|
||||
const printfRaw = withoutEscapedPercents.match(/%(?:\d+\$)?(?:\.\d+)?(?:@|d|f)/g) ?? [];
|
||||
|
||||
const printf = {};
|
||||
let implicitIndex = 1;
|
||||
for (const token of printfRaw) {
|
||||
const match = token.match(/%(\d+)\$.*?([@df])/);
|
||||
if (match) {
|
||||
printf[Number.parseInt(match[1], 10)] = match[2];
|
||||
} else {
|
||||
printf[implicitIndex] = token.at(-1);
|
||||
implicitIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { printf, swift: swiftInterpolationTokens(value).sort() };
|
||||
}
|
||||
|
||||
function formatKeyList(keys, limit = 12) {
|
||||
const shown = keys.slice(0, limit).join(", ");
|
||||
const remaining = keys.length - limit;
|
||||
return remaining > 0 ? `${shown}, ... +${remaining} more` : shown;
|
||||
}
|
||||
|
||||
function blankKeys(catalog, referenceKeys) {
|
||||
return referenceKeys.filter((key) => Object.hasOwn(catalog, key) && !catalog[key]?.trim());
|
||||
}
|
||||
|
||||
function swiftInterpolationTokens(value) {
|
||||
const tokens = [];
|
||||
for (let index = 0; index < value.length - 1; index += 1) {
|
||||
if (value[index] !== "\\" || value[index + 1] !== "(") continue;
|
||||
|
||||
const start = index;
|
||||
let depth = 1;
|
||||
index += 2;
|
||||
while (index < value.length && depth > 0) {
|
||||
if (value[index] === "(") depth += 1;
|
||||
if (value[index] === ")") depth -= 1;
|
||||
index += 1;
|
||||
}
|
||||
tokens.push(value.slice(start, index));
|
||||
index -= 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
if (isTest) {
|
||||
assertEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%2$d · %1$@"), "positional reorder");
|
||||
assertNotEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%1$d · %2$@"), "positional type swap");
|
||||
assertEqual(tokenSignature("%.0f%% used"), tokenSignature("%.0f%% verbraucht"), "escaped percent");
|
||||
assertNotEqual(tokenSignature("\\(name): \\(usage)"), tokenSignature("\\(name): \\(value)"), "Swift tokens");
|
||||
assertEqual(
|
||||
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
|
||||
tokenSignature("Fehler: \\(self.store.metadata(for: self.provider).displayName)"),
|
||||
"nested Swift interpolation");
|
||||
assertNotEqual(
|
||||
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
|
||||
tokenSignature("\\(self.store.metadata(for: self.provider) failed"),
|
||||
"truncated Swift interpolation");
|
||||
assertEqual(formatKeyList(["alpha", "beta"]), "alpha, beta", "short key list");
|
||||
assertEqual(
|
||||
formatKeyList(["alpha", "beta", "gamma", "delta"], 2),
|
||||
"alpha, beta, ... +2 more",
|
||||
"truncated key list");
|
||||
assertEqual(
|
||||
blankKeys({ alpha: "", beta: " ", gamma: "ok" }, ["alpha", "beta", "gamma", "delta"]),
|
||||
["alpha", "beta"],
|
||||
"blank keys");
|
||||
console.log("app locale checker tests OK");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
let checkedCount = 0;
|
||||
|
||||
for (const strictLocale of strictLocales) {
|
||||
const dirPath = path.join(resources, `${strictLocale}.lproj`);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
console.error(`\x1b[31mError: Required strict locale catalog is completely missing: ${strictLocale}.lproj\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const directory of fs.readdirSync(resources).filter((name) => name.endsWith(".lproj"))) {
|
||||
const locale = directory.replace(/\.lproj$/, "");
|
||||
if (locale === "en" || locale === "Base") continue;
|
||||
|
||||
const catalog = readCatalog(locale);
|
||||
if (!catalog) continue;
|
||||
|
||||
checkedCount++;
|
||||
const catalogKeys = Object.keys(catalog);
|
||||
const emptyKeys = blankKeys(catalog, englishKeys);
|
||||
|
||||
// 1. Missing keys
|
||||
const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key));
|
||||
if (missingKeys.length > 0) {
|
||||
const missingKeyList = formatKeyList(missingKeys);
|
||||
if (strictLocales.includes(locale)) {
|
||||
console.error(
|
||||
`\x1b[31m[${locale}] Error: Missing ${missingKeys.length} keys in strict locale: ${missingKeyList}.\x1b[0m`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.warn(`\x1b[33m[${locale}] Warning: Missing ${missingKeys.length} keys: ${missingKeyList}.\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
const extraKeys = catalogKeys.filter((key) => !englishKeys.includes(key));
|
||||
if (strictLocales.includes(locale) && extraKeys.length > 0) {
|
||||
console.error(`\x1b[31m[${locale}] Error: Found ${extraKeys.length} extra keys in strict locale.\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Ensure critical language keys are present in ALL locales
|
||||
for (const key of languageKeys) {
|
||||
if (!catalog[key] || !catalog[key].trim()) {
|
||||
console.error(`\x1b[31m[${locale}] Error: Missing critical language key "${key}".\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyKeys.length > 0) {
|
||||
console.error(
|
||||
`\x1b[31m[${locale}] Error: Blank values for ${emptyKeys.length} keys: ${formatKeyList(emptyKeys)}.\x1b[0m`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// 2. Identical values count
|
||||
let identicalCount = 0;
|
||||
|
||||
for (const key of englishKeys) {
|
||||
if (!catalog[key]?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (catalog[key] === english[key]) {
|
||||
identicalCount++;
|
||||
}
|
||||
|
||||
// 3. Format placeholder mismatch
|
||||
const tEn = tokenSignature(english[key]);
|
||||
const tLoc = tokenSignature(catalog[key]);
|
||||
if (JSON.stringify(tEn) !== JSON.stringify(tLoc)) {
|
||||
console.error(`\x1b[31m[${locale}] Error: Token mismatch for key "${key}"\x1b[0m`);
|
||||
console.error(` en: ${english[key]} Tokens: ${JSON.stringify(tEn)}`);
|
||||
console.error(` ${locale}: ${catalog[key]} Tokens: ${JSON.stringify(tLoc)}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if identical translation count exceeds 15% of the total keys (approx > 150 out of 1050)
|
||||
const identicalRatio = identicalCount / englishKeys.length;
|
||||
if (identicalRatio > 0.15) {
|
||||
console.warn(`\x1b[33m[${locale}] Warning: High number of identical translations: ${identicalCount}/${englishKeys.length} (${(identicalRatio * 100).toFixed(1)}%)\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
console.error("\n\x1b[31mApp locale checks failed.\x1b[0m");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n\x1b[32mApp locales OK: Checked ${checkedCount} catalogs against ${englishKeys.length} English keys.\x1b[0m`);
|
||||
|
||||
function assertEqual(actual, expected, label) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotEqual(actual, expected, label) {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
||||
throw new Error(`${label}: signatures unexpectedly match`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const approvedRootDocumentation = new Set([
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"VISION.md",
|
||||
].map((relativePath) => path.join(repoRoot, relativePath)));
|
||||
|
||||
const readme = readText("README.md");
|
||||
const readmeLinks = [
|
||||
...markdownLinks(readme),
|
||||
...markdownImageLinks(readme),
|
||||
...htmlLinks(readme),
|
||||
].filter(isRepositoryDocReference);
|
||||
|
||||
assert(readmeLinks.length > 0, "README.md has no local documentation links");
|
||||
for (const link of readmeLinks) validateLocalDocLink(link, repoRoot, "README.md");
|
||||
|
||||
const providerLinks = inlineCodeDocLinks(readText("docs/providers.md"));
|
||||
assert(providerLinks.length > 0, "docs/providers.md has no provider detail links");
|
||||
for (const link of providerLinks) validateLocalDocLink(link, repoRoot, "docs/providers.md");
|
||||
|
||||
const docsLinks = markdownFiles("docs").flatMap((relativePath) => {
|
||||
const markdown = readText(relativePath);
|
||||
const links = [
|
||||
...markdownLinks(markdown),
|
||||
...markdownImageLinks(markdown),
|
||||
...htmlLinks(markdown),
|
||||
].filter(isLocalDocumentationReference);
|
||||
|
||||
return links.map((link) => ({ link, relativePath }));
|
||||
});
|
||||
|
||||
for (const { link, relativePath } of docsLinks) {
|
||||
validateLocalDocLink(link, path.join(repoRoot, path.dirname(relativePath)), relativePath);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`documentation links OK: ${readmeLinks.length + providerLinks.length + docsLinks.length} local links`,
|
||||
);
|
||||
|
||||
function readText(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function markdownLinks(markdown) {
|
||||
const source = markdownTextOutsideCode(markdown);
|
||||
const links = [];
|
||||
const inlinePattern = /(?<!!)\[(?:\\.|[^\]\\])+\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g;
|
||||
for (const match of source.matchAll(inlinePattern)) {
|
||||
links.push(encodeSpaces(match[1] ?? match[2]));
|
||||
}
|
||||
|
||||
const referencePattern = /^\s*\[[^\]\n]+]:\s*(?:<([^>\n]+)>|([^\s]+))/gm;
|
||||
for (const match of source.matchAll(referencePattern)) {
|
||||
links.push(encodeSpaces(match[1] ?? match[2]));
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function markdownImageLinks(markdown) {
|
||||
const source = markdownTextOutsideCode(markdown);
|
||||
const pattern = /!\[(?:\\.|[^\]\\])*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g;
|
||||
return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2]);
|
||||
}
|
||||
|
||||
function htmlLinks(markdown) {
|
||||
const source = markdownTextOutsideCode(markdown);
|
||||
const pattern = /<\s*(?:a|img)\b[^>]*?\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
|
||||
return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2] ?? match[3]);
|
||||
}
|
||||
|
||||
function inlineCodeDocLinks(markdown) {
|
||||
return markdown.split("\n").flatMap((line) => {
|
||||
const trimmed = line.trim();
|
||||
const prefix = "- Details: `";
|
||||
if (!trimmed.startsWith(prefix)) return [];
|
||||
const rest = trimmed.slice(prefix.length);
|
||||
const end = rest.indexOf("`");
|
||||
return end === -1 ? [] : [rest.slice(0, end)];
|
||||
});
|
||||
}
|
||||
|
||||
function validateLocalDocLink(rawLink, baseDirectory, sourceLabel) {
|
||||
const sourcePath = path.join(repoRoot, sourceLabel);
|
||||
const { absolutePath, fragment } = localDocPath(rawLink, baseDirectory, sourcePath);
|
||||
assert(fs.existsSync(absolutePath), `${sourceLabel}: missing documentation target: ${rawLink}`);
|
||||
|
||||
if (path.extname(absolutePath).toLowerCase() !== ".md" || !fragment) return;
|
||||
const anchors = markdownHeadingAnchors(readText(path.relative(repoRoot, absolutePath)));
|
||||
assert(anchors.has(fragment), `${sourceLabel}: missing documentation anchor: ${rawLink}`);
|
||||
}
|
||||
|
||||
function isRepositoryDocReference(rawLink) {
|
||||
const parsed = parseRelativeURL(rawLink);
|
||||
if (!parsed || parsed.protocol || parsed.host) return false;
|
||||
let pathname = parsed.pathname;
|
||||
while (pathname.startsWith("./")) pathname = pathname.slice(2);
|
||||
return pathname === "docs" || pathname.startsWith("docs/");
|
||||
}
|
||||
|
||||
function isLocalDocumentationReference(rawLink) {
|
||||
const parsed = parseRelativeURL(rawLink);
|
||||
if (!parsed || parsed.protocol || parsed.host) return false;
|
||||
return Boolean(parsed.pathname || parsed.hash);
|
||||
}
|
||||
|
||||
function localDocPath(rawLink, baseDirectory, sourcePath) {
|
||||
const parsed = parseRelativeURL(rawLink);
|
||||
assert(
|
||||
parsed && !parsed.protocol && !parsed.host && (parsed.pathname || parsed.hash),
|
||||
`invalid documentation URL: ${rawLink}`,
|
||||
);
|
||||
|
||||
const rawPath = rawLink.split("#", 1)[0].split("?", 1)[0];
|
||||
const decodedPath = decodeURIComponent(rawPath);
|
||||
const absolutePath = decodedPath ? path.resolve(baseDirectory, decodedPath) : sourcePath;
|
||||
const docsRoot = path.resolve(repoRoot, "docs");
|
||||
const isInDocsTree = absolutePath === docsRoot || absolutePath.startsWith(`${docsRoot}${path.sep}`);
|
||||
assert(
|
||||
isInDocsTree || approvedRootDocumentation.has(absolutePath),
|
||||
`documentation link escapes approved documentation roots: ${rawLink}`,
|
||||
);
|
||||
return { absolutePath, fragment: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "" };
|
||||
}
|
||||
|
||||
function markdownFiles(relativeDir) {
|
||||
const dir = path.join(repoRoot, relativeDir);
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") return [];
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) return markdownFiles(relativePath);
|
||||
return entry.isFile() && entry.name.endsWith(".md") ? [relativePath] : [];
|
||||
}).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function parseRelativeURL(rawLink) {
|
||||
try {
|
||||
const parsed = new URL(rawLink, "relative://repo/");
|
||||
const isRelative = parsed.protocol === "relative:" && parsed.host === "repo";
|
||||
return {
|
||||
protocol: isRelative ? "" : parsed.protocol,
|
||||
host: isRelative ? "" : parsed.host,
|
||||
pathname: isRelative ? parsed.pathname.replace(/^\//, "") : parsed.pathname,
|
||||
hash: parsed.hash,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function markdownHeadingAnchors(markdown) {
|
||||
const occurrences = new Map();
|
||||
const anchors = new Set();
|
||||
const source = markdownTextOutsideFencedCode(markdown);
|
||||
for (const line of source.split("\n")) {
|
||||
const trimmed = line.replace(/^[ \t]+/, "");
|
||||
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(trimmed);
|
||||
if (!match) continue;
|
||||
const base = markdownHeadingSlug(match[2]);
|
||||
if (!base) continue;
|
||||
const occurrence = occurrences.get(base) ?? 0;
|
||||
anchors.add(occurrence === 0 ? base : `${base}-${occurrence}`);
|
||||
occurrences.set(base, occurrence + 1);
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function markdownHeadingSlug(heading) {
|
||||
const text = removeMarkdownFormatting(heading).toLowerCase();
|
||||
let slug = "";
|
||||
for (const char of text) {
|
||||
if (/[\p{Letter}\p{Number}_-]/u.test(char)) {
|
||||
slug += char;
|
||||
} else if (/\s/u.test(char)) {
|
||||
slug += "-";
|
||||
}
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function removeMarkdownFormatting(text) {
|
||||
return text
|
||||
.replace(/`([^`]*)`/g, "$1")
|
||||
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
|
||||
.replace(/[*_~]/g, "");
|
||||
}
|
||||
|
||||
function markdownTextOutsideCode(markdown) {
|
||||
return markdownTextOutsideFencedCode(markdown)
|
||||
.split("\n")
|
||||
.map(removeInlineCode)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function markdownTextOutsideFencedCode(markdown) {
|
||||
let fence = null;
|
||||
return markdown.split("\n").map((line) => {
|
||||
if (fence) {
|
||||
if (isClosingFence(line, fence.marker, fence.count)) fence = null;
|
||||
return "";
|
||||
}
|
||||
const openingFence = parseOpeningFence(line);
|
||||
if (openingFence) {
|
||||
fence = openingFence;
|
||||
return "";
|
||||
}
|
||||
return line;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function parseOpeningFence(line) {
|
||||
const match = /^( {0,3})([`~]{3,})(.*)$/.exec(line);
|
||||
if (!match) return null;
|
||||
const marker = match[2][0];
|
||||
if (marker === "`" && match[3].includes("`")) return null;
|
||||
return { marker, count: match[2].length };
|
||||
}
|
||||
|
||||
function isClosingFence(line, marker, minimumCount) {
|
||||
const escaped = marker === "`" ? "`" : "~";
|
||||
const pattern = new RegExp(`^ {0,3}${escaped}{${minimumCount},}\\s*$`);
|
||||
return pattern.test(line);
|
||||
}
|
||||
|
||||
function removeInlineCode(line) {
|
||||
return line.replace(/(?<!`)(`+)(?!`)(.*?)(?<!`)\1(?!`)/g, "");
|
||||
}
|
||||
|
||||
function encodeSpaces(value) {
|
||||
return value.replaceAll(" ", "%20");
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" check-assets "$@"
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { localeCatalog, localeMessages } from "../docs/site-locales.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const indexHtml = fs.readFileSync(path.join(repoRoot, "docs/index.html"), "utf8");
|
||||
const providerSource = fs.readFileSync(
|
||||
path.join(repoRoot, "Sources/CodexBarCore/Providers/Providers.swift"),
|
||||
"utf8",
|
||||
);
|
||||
const providerEnumBody = providerSource.match(
|
||||
/public enum UsageProvider:[^{]+\{([\s\S]*?)\n\}/,
|
||||
)?.[1];
|
||||
assert(providerEnumBody, "could not locate UsageProvider cases");
|
||||
const providerIDs = [...providerEnumBody.matchAll(/^\s*case\s+(\w+)\s*$/gm)].map((match) => match[1]);
|
||||
assert(providerIDs.length > 0, "UsageProvider must define at least one provider");
|
||||
assertEqual(new Set(providerIDs).size, providerIDs.length, "UsageProvider IDs");
|
||||
const providerCount = providerIDs.length;
|
||||
|
||||
const publicCountFiles = [
|
||||
["README.md", `alt="CodexBar — every AI coding limit in your menu bar. ${providerCount} providers."`],
|
||||
["docs/providers.md", `CodexBar currently registers ${providerCount} provider IDs.`],
|
||||
["docs/social.html", `<strong>${providerCount} providers</strong>`],
|
||||
["docs/llms.txt", `across ${providerCount} providers`],
|
||||
];
|
||||
for (const [relativePath, expectedText] of publicCountFiles) {
|
||||
const contents = fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
assert(contents.includes(expectedText), `${relativePath} must advertise ${providerCount} providers`);
|
||||
}
|
||||
assert(indexHtml.includes(`across ${providerCount} providers`), `index metadata must advertise ${providerCount} providers`);
|
||||
assert(
|
||||
indexHtml.includes(`across ${providerCount} AI coding providers`),
|
||||
`index social metadata must advertise ${providerCount} providers`,
|
||||
);
|
||||
assert(
|
||||
indexHtml.includes(`>${providerCount} providers,{mobileBreak}one menu bar</span>`),
|
||||
`index provider heading must advertise ${providerCount} providers`,
|
||||
);
|
||||
|
||||
assert(!indexHtml.includes("cdn.tailwindcss.com"), "site must not load Tailwind from a runtime CDN");
|
||||
for (const match of indexHtml.matchAll(/<link rel="stylesheet" href="\.\/([^"?]+)(?:\?[^"']*)?"/g)) {
|
||||
assert(fs.existsSync(path.join(repoRoot, "docs", match[1])), `missing local stylesheet ${match[1]}`);
|
||||
}
|
||||
const expectedCodes = [
|
||||
"en", "zh-CN", "zh-TW", "ja-JP", "es", "pt-BR", "ko", "de", "fr", "ar", "it",
|
||||
"vi", "nl", "tr", "uk", "ru", "id", "pl", "fa", "th", "gl", "ca", "sv",
|
||||
];
|
||||
const catalogCodes = localeCatalog.map((locale) => locale.code);
|
||||
const appLanguageSource = fs.readFileSync(
|
||||
path.join(repoRoot, "Sources/CodexBar/PreferencesGeneralPane.swift"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assertEqual(catalogCodes, expectedCodes, "locale catalog");
|
||||
assertEqual(
|
||||
localeCatalog.filter((locale) => locale.direction === "rtl").map((locale) => locale.code),
|
||||
["ar", "fa"],
|
||||
"RTL locale catalog");
|
||||
const appCatalogCodes = [...appLanguageSource.matchAll(/case \w+ = "([^"]+)"/g)]
|
||||
.map((match) => match[1])
|
||||
.filter(Boolean)
|
||||
.map((code) => ({ "zh-Hans": "zh-CN", "zh-Hant": "zh-TW", ja: "ja-JP" })[code] ?? code);
|
||||
assertEqual(appCatalogCodes, expectedCodes, "app language catalog");
|
||||
|
||||
const englishKeys = Object.keys(localeMessages.en).sort();
|
||||
for (const locale of localeCatalog) {
|
||||
const messages = localeMessages[locale.code];
|
||||
assert(messages, `missing messages for ${locale.code}`);
|
||||
assertEqual(Object.keys(messages).sort(), englishKeys, `${locale.code} message keys`);
|
||||
|
||||
for (const key of ["meta.description", "meta.ogDescription", "providers.title"]) {
|
||||
const counts = [...messages[key].matchAll(/\d+/g)].map(Number);
|
||||
assertEqual(counts[0], providerCount, `${locale.code}.${key} provider count`);
|
||||
}
|
||||
|
||||
for (const key of englishKeys) {
|
||||
assert(messages[key].trim(), `${locale.code}.${key} is blank`);
|
||||
assertEqual(tokens(messages[key]), tokens(localeMessages.en[key]), `${locale.code}.${key} tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
const referencedKeys = new Set();
|
||||
for (const match of indexHtml.matchAll(/data-i18n(?:-rich|-aria-label|-title|-alt)?="([^"]+)"/g)) {
|
||||
referencedKeys.add(match[1]);
|
||||
}
|
||||
for (const key of referencedKeys) {
|
||||
assert(englishKeys.includes(key), `index.html references unknown locale key ${key}`);
|
||||
}
|
||||
|
||||
const siteJs = fs.readFileSync(path.join(repoRoot, 'docs/site.js'), 'utf8');
|
||||
const hasLanguagePicker = indexHtml.includes('id="language-picker-list"')
|
||||
&& (indexHtml.includes('localeCatalog') || siteJs.includes('localeCatalog'));
|
||||
assert(hasLanguagePicker, 'site must include the language picker backed by localeCatalog');
|
||||
|
||||
for (const code of catalogCodes) {
|
||||
assert(indexHtml.includes(`href="https://codexbar.app/?lang=${code}"`), `missing hreflang URL for ${code}`);
|
||||
}
|
||||
|
||||
const providerCards = [...indexHtml.matchAll(/<li class="provider-card"([^>]*)>([\s\S]*?)<\/li>/g)];
|
||||
for (const [, attrs, body] of providerCards) {
|
||||
if (!attrs.includes('hidden')) {
|
||||
assert(body.includes('class="provider-card-link"'), 'provider cards must link to provider documentation');
|
||||
assert(body.includes('class="provider-logo'), 'provider cards must use logo assets');
|
||||
for (const match of body.matchAll(/src="\.\/([^"]+)"/g)) {
|
||||
assert(fs.existsSync(path.join(repoRoot, 'docs', match[1])), `missing provider logo asset ${match[1]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`app/site locales OK: ${catalogCodes.length} locales, ${englishKeys.length} site messages`);
|
||||
|
||||
function tokens(value) {
|
||||
return [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]).sort();
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, label) {
|
||||
const actualJSON = JSON.stringify(actual);
|
||||
const expectedJSON = JSON.stringify(expected);
|
||||
if (actualJSON !== expectedJSON) {
|
||||
throw new Error(`${label}: expected ${expectedJSON}, got ${actualJSON}`);
|
||||
}
|
||||
}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAX_BYTES=$((2 * 1024 * 1024))
|
||||
failures=0
|
||||
tracked_files=0
|
||||
declare -a blob_paths=()
|
||||
declare -a blob_ids=()
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
while IFS= read -r -d '' entry; do
|
||||
metadata=${entry%%$'\t'*}
|
||||
path=${entry#*$'\t'}
|
||||
read -r mode object stage <<<"$metadata"
|
||||
[[ "$stage" == "0" ]] || continue
|
||||
tracked_files=$((tracked_files + 1))
|
||||
|
||||
case "$path" in
|
||||
*.app | *.app/* | *.dSYM | *.dSYM/* | *.xcarchive/* | *.xcresult/* | *.ipa | *.zip | *.delta | *.dmg | \
|
||||
*.pkg | *.tar.gz | *.tgz)
|
||||
printf 'ERROR: generated artifact is tracked: %s\n' "$path" >&2
|
||||
failures=$((failures + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
# Submodule entries name commits rather than file blobs.
|
||||
[[ "$mode" == "160000" ]] && continue
|
||||
blob_paths+=("$path")
|
||||
blob_ids+=("$object")
|
||||
done < <(git ls-files --stage -z)
|
||||
|
||||
if ((${#blob_ids[@]} > 0)); then
|
||||
index=0
|
||||
while read -r object type size; do
|
||||
path=${blob_paths[$index]}
|
||||
if [[ "$type" != "blob" ]]; then
|
||||
printf 'ERROR: tracked index entry is not a readable blob: %q (%s)\n' "$path" "$object" >&2
|
||||
failures=$((failures + 1))
|
||||
index=$((index + 1))
|
||||
continue
|
||||
fi
|
||||
if ((size > MAX_BYTES)); then
|
||||
printf 'ERROR: tracked file exceeds %d bytes: %q (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done < <(printf '%s\n' "${blob_ids[@]}" | git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize)')
|
||||
fi
|
||||
|
||||
if ((failures > 0)); then
|
||||
printf 'Repository size check failed with %d violation(s).\n' "$failures" >&2
|
||||
printf 'Publish build/release artifacts outside Git and optimize required source assets.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'repository size OK: %d tracked files, maximum %d bytes each\n' "$tracked_files" "$MAX_BYTES"
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check for new changes in upstream repositories
|
||||
# Usage: ./Scripts/check_upstreams.sh [upstream|quotio|all]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGET=${1:-all}
|
||||
DAYS=${2:-7}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}==> Fetching upstream changes...${NC}"
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
|
||||
git fetch upstream 2>/dev/null || {
|
||||
echo -e "${YELLOW}Adding upstream remote...${NC}"
|
||||
git remote add upstream https://github.com/steipete/CodexBar.git
|
||||
git fetch upstream
|
||||
}
|
||||
fi
|
||||
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
|
||||
git fetch quotio 2>/dev/null || {
|
||||
echo -e "${YELLOW}Adding quotio remote...${NC}"
|
||||
git remote add quotio https://github.com/nguyenphutrong/quotio.git
|
||||
git fetch quotio
|
||||
}
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
remote_default_branch() {
|
||||
local remote=$1
|
||||
local branch=""
|
||||
local candidate
|
||||
|
||||
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
|
||||
if [ -z "$branch" ]; then
|
||||
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
|
||||
fi
|
||||
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in main master; do
|
||||
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check upstream (steipete)
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
|
||||
echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}"
|
||||
UPSTREAM_BRANCH=$(remote_default_branch upstream)
|
||||
UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}"
|
||||
|
||||
UPSTREAM_COUNT=$(git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$UPSTREAM_COUNT" -gt 0 ]; then
|
||||
echo -e "${GREEN}Found $UPSTREAM_COUNT new commits${NC}"
|
||||
echo ""
|
||||
git log --oneline --graph "main..${UPSTREAM_REF}" --no-merges | head -20 || true
|
||||
echo ""
|
||||
echo -e "${YELLOW}Files changed:${NC}"
|
||||
git diff --stat "main..${UPSTREAM_REF}" | tail -20 || true
|
||||
else
|
||||
echo -e "${GREEN}No new commits (up to date)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check quotio
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
|
||||
echo -e "${BLUE}==> Quotio changes (last $DAYS days):${NC}"
|
||||
QUOTIO_BRANCH=$(remote_default_branch quotio)
|
||||
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
|
||||
|
||||
QUOTIO_COUNT=$(git log --oneline "$QUOTIO_REF" --since="$DAYS days ago" 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$QUOTIO_COUNT" -gt 0 ]; then
|
||||
echo -e "${GREEN}Found $QUOTIO_COUNT commits in last $DAYS days${NC}"
|
||||
echo ""
|
||||
git log --oneline --graph "$QUOTIO_REF" --since="$DAYS days ago" | head -20 || true
|
||||
echo ""
|
||||
echo -e "${YELLOW}Recent file changes:${NC}"
|
||||
# Show changes from last 10 commits
|
||||
git diff --stat "${QUOTIO_REF}~10..${QUOTIO_REF}" 2>/dev/null | tail -20 || echo "Unable to show diff"
|
||||
else
|
||||
echo -e "${GREEN}No new commits in last $DAYS days${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo -e "${BLUE}==> Summary${NC}"
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
|
||||
echo "Upstream commits: $UPSTREAM_COUNT"
|
||||
fi
|
||||
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
|
||||
echo "Quotio commits (${DAYS}d): $QUOTIO_COUNT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo " Review upstream: ./Scripts/review_upstream.sh upstream"
|
||||
echo " Review quotio: ./Scripts/review_upstream.sh quotio"
|
||||
echo " Detailed diff: git diff main..<resolved-remote>/<default-branch>"
|
||||
echo " View quotio: ./Scripts/analyze_quotio.sh"
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
changed_paths_file="${1:-}"
|
||||
|
||||
if [[ -z "$changed_paths_file" || ! -f "$changed_paths_file" ]]; then
|
||||
printf 'Usage: %s <changed-paths-file>\n' "$(basename "$0")" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
macos_tests=false
|
||||
macos_tests_reason=""
|
||||
path_count=0
|
||||
|
||||
require_macos_tests() {
|
||||
local path="$1"
|
||||
local reason="$2"
|
||||
|
||||
macos_tests=true
|
||||
if [[ -z "$macos_tests_reason" ]]; then
|
||||
macos_tests_reason="${path}: ${reason}"
|
||||
fi
|
||||
}
|
||||
|
||||
classify_path() {
|
||||
local path="$1"
|
||||
[[ -z "$path" ]] && return
|
||||
|
||||
path_count=$((path_count + 1))
|
||||
|
||||
case "$path" in
|
||||
AGENTS.md|docs/configuration.md)
|
||||
require_macos_tests "$path" "changes contributor or runtime configuration contracts"
|
||||
;;
|
||||
*.md)
|
||||
;;
|
||||
docs/.nojekyll|docs/CNAME|docs/index.html|docs/llms.txt|docs/site-locales.mjs|docs/site.css|docs/site.js|docs/social.html|docs/social.png)
|
||||
;;
|
||||
docs/*.png|docs/*.jpg|docs/*.jpeg|docs/*.webp|docs/*.ico|docs/*.svg)
|
||||
;;
|
||||
*)
|
||||
require_macos_tests "$path" "not covered by portable docs/site checks"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
invalid_row=false
|
||||
while IFS=$'\t' read -r status first_path second_path extra_path \
|
||||
|| [[ -n "${status:-}${first_path:-}${second_path:-}${extra_path:-}" ]]
|
||||
do
|
||||
[[ -z "${status}${first_path:-}${second_path:-}${extra_path:-}" ]] && continue
|
||||
|
||||
case "$status" in
|
||||
R*|C*)
|
||||
if ! [[ "$status" =~ ^[RC][0-9]{1,3}$ ]] \
|
||||
|| ((10#${status:1} > 100)) \
|
||||
|| [[ -z "${first_path:-}" || -z "${second_path:-}" || -n "${extra_path:-}" ]]
|
||||
then
|
||||
invalid_row=true
|
||||
break
|
||||
fi
|
||||
classify_path "$first_path"
|
||||
classify_path "$second_path"
|
||||
;;
|
||||
A|D|M|T|U|X|B)
|
||||
if [[ -z "${first_path:-}" || -n "${second_path:-}" || -n "${extra_path:-}" ]]; then
|
||||
invalid_row=true
|
||||
break
|
||||
fi
|
||||
classify_path "$first_path"
|
||||
;;
|
||||
*)
|
||||
invalid_row=true
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done < "$changed_paths_file"
|
||||
|
||||
if [[ "$invalid_row" == true ]]; then
|
||||
printf 'Invalid git name-status row; refusing to skip macOS tests.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$path_count" -eq 0 ]]; then
|
||||
require_macos_tests '<empty diff>' 'no changed paths were reported'
|
||||
fi
|
||||
|
||||
if [[ "$macos_tests" == true ]]; then
|
||||
summary_reason="$macos_tests_reason"
|
||||
else
|
||||
summary_reason="docs/site-only changes covered by portable checks"
|
||||
fi
|
||||
|
||||
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
|
||||
printf 'macos-tests=%s\n' "$macos_tests" >> "$GITHUB_OUTPUT"
|
||||
printf 'macos-tests-reason=%s\n' "$summary_reason" >> "$GITHUB_OUTPUT"
|
||||
printf 'changed-path-count=%s\n' "$path_count" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [[ "$macos_tests" == true ]]; then
|
||||
printf 'macOS Swift tests required for this change set: %s.\n' "$macos_tests_reason"
|
||||
else
|
||||
printf 'Skipping macOS Swift tests for docs/site-only changes covered by portable checks.\n'
|
||||
fi
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run SwiftPM tests in suite shards so CI cannot hang inside one aggregate run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestSelection:
|
||||
name: str
|
||||
filter_pattern: str
|
||||
suite_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunStats:
|
||||
discovered_selections: int = 0
|
||||
selected_selections: int = 0
|
||||
selected_groups: int = 0
|
||||
group_size: int = 0
|
||||
shard_index: int | None = None
|
||||
shard_count: int | None = None
|
||||
discovery_seconds: float = 0
|
||||
execution_seconds: float = 0
|
||||
total_seconds: float = 0
|
||||
first_pass_successful_groups: int = 0
|
||||
first_pass_failed_groups: int = 0
|
||||
full_group_retries: int = 0
|
||||
timed_out_groups: int = 0
|
||||
recovered_groups: int = 0
|
||||
isolated_selection_retries: int = 0
|
||||
|
||||
def summary_rows(self) -> list[tuple[str, str]]:
|
||||
shard = "none"
|
||||
if self.shard_index is not None and self.shard_count is not None:
|
||||
shard = f"{self.shard_index + 1}/{self.shard_count}"
|
||||
return [
|
||||
("Shard", shard),
|
||||
("Group size", str(self.group_size)),
|
||||
("Discovered selections", str(self.discovered_selections)),
|
||||
("Selected selections", str(self.selected_selections)),
|
||||
("Selected groups", str(self.selected_groups)),
|
||||
("First-pass successful groups", str(self.first_pass_successful_groups)),
|
||||
("First-pass failed groups", str(self.first_pass_failed_groups)),
|
||||
("Full-group retries", str(self.full_group_retries)),
|
||||
("Recovered groups", str(self.recovered_groups)),
|
||||
("Timed out groups", str(self.timed_out_groups)),
|
||||
("Isolated selection retries", str(self.isolated_selection_retries)),
|
||||
("Discovery seconds", f"{self.discovery_seconds:.1f}"),
|
||||
("Execution seconds", f"{self.execution_seconds:.1f}"),
|
||||
("Total seconds", f"{self.total_seconds:.1f}"),
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--group-size", type=int, default=12)
|
||||
parser.add_argument("--timeout", type=int, default=180)
|
||||
parser.add_argument("--limit-groups", type=int)
|
||||
parser.add_argument("--shard-index", type=int)
|
||||
parser.add_argument("--shard-count", type=int)
|
||||
parser.add_argument(
|
||||
"--no-retry-non-timeout-failures",
|
||||
action="store_false",
|
||||
dest="retry_non_timeout_failures",
|
||||
help="fail immediately when a group exits without timing out",
|
||||
)
|
||||
parser.add_argument("--list-only", action="store_true")
|
||||
parser.add_argument("--swift-command", default="swift")
|
||||
parser.add_argument("--swift-command-arg", action="append", default=[])
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def run_command(command: list[str], timeout: int | None = None) -> int:
|
||||
print(f"+ {' '.join(command)}", flush=True)
|
||||
process = subprocess.Popen(command, start_new_session=True)
|
||||
try:
|
||||
return process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"::warning::Command timed out after {timeout}s: {' '.join(command)}", flush=True)
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
return 124
|
||||
|
||||
|
||||
def swift_test_list(swift_command: list[str]) -> list[TestSelection]:
|
||||
command = [*swift_command, "test", "list"]
|
||||
try:
|
||||
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as error:
|
||||
print(f"+ {swift_command[0]} test list", flush=True)
|
||||
if error.stdout:
|
||||
print(error.stdout, end="" if error.stdout.endswith("\n") else "\n", flush=True)
|
||||
if error.stderr:
|
||||
print(error.stderr, end="" if error.stderr.endswith("\n") else "\n", file=sys.stderr, flush=True)
|
||||
raise
|
||||
selections: set[TestSelection] = set()
|
||||
unknown: list[str] = []
|
||||
for line in result.stdout.splitlines():
|
||||
top_level = re.fullmatch(r"(?P<module>[^.]+)\.(?:`(?P<display>.+)`|(?P<function>[^()/]+))\(\)", line)
|
||||
if top_level is not None:
|
||||
module = top_level.group("module")
|
||||
test_name = top_level.group("display") or top_level.group("function")
|
||||
selections.add(
|
||||
TestSelection(
|
||||
name=line,
|
||||
# SwiftPM matches top-level Swift Testing functions by their display name,
|
||||
# not the backtick-wrapped identifier printed by `swift test list`.
|
||||
filter_pattern=rf"{re.escape(module)}\..*{re.escape(test_name)}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if "/" in line:
|
||||
suite = line.split("/", 1)[0]
|
||||
if "." in suite:
|
||||
selections.add(
|
||||
TestSelection(
|
||||
name=suite,
|
||||
filter_pattern=rf"^{re.escape(suite)}/",
|
||||
suite_name=suite,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
unknown.append(line)
|
||||
|
||||
if unknown:
|
||||
rendered = "\n".join(f"- {line}" for line in unknown)
|
||||
raise RuntimeError(f"Unrecognized `swift test list` output:\n{rendered}")
|
||||
return sorted(selections, key=lambda selection: selection.name)
|
||||
|
||||
|
||||
def append_github_summary(stats: RunStats) -> None:
|
||||
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not summary_path:
|
||||
return
|
||||
|
||||
with open(summary_path, "a", encoding="utf-8") as summary:
|
||||
summary.write("### macOS Swift test timing\n\n")
|
||||
summary.write("| Field | Value |\n")
|
||||
summary.write("| --- | --- |\n")
|
||||
for field, value in stats.summary_rows():
|
||||
safe_value = value.replace("|", "\\|")
|
||||
summary.write(f"| {field} | `{safe_value}` |\n")
|
||||
summary.write("\n")
|
||||
|
||||
|
||||
def print_timing_summary(stats: RunStats) -> None:
|
||||
print("Swift test timing summary:", flush=True)
|
||||
for field, value in stats.summary_rows():
|
||||
print(f"- {field}: {value}", flush=True)
|
||||
|
||||
|
||||
def chunks(items: list[TestSelection], size: int) -> Iterable[list[TestSelection]]:
|
||||
for index in range(0, len(items), size):
|
||||
yield items[index : index + size]
|
||||
|
||||
|
||||
def shard_groups(groups: list[list[TestSelection]], shard_index: int | None, shard_count: int | None) -> list[list[TestSelection]]:
|
||||
if shard_index is None and shard_count is None:
|
||||
return groups
|
||||
if shard_index is None or shard_count is None:
|
||||
raise ValueError("--shard-index and --shard-count must be passed together")
|
||||
if shard_count < 1:
|
||||
raise ValueError("--shard-count must be positive")
|
||||
if shard_index < 0 or shard_index >= shard_count:
|
||||
raise ValueError("--shard-index must be in the range [0, --shard-count)")
|
||||
return [group for index, group in enumerate(groups) if index % shard_count == shard_index]
|
||||
|
||||
|
||||
def prioritized_suites(suites: list[TestSelection]) -> list[TestSelection]:
|
||||
priority = ["CodexBarTests.CLIEntryTests"]
|
||||
ordered = [suite for name in priority for suite in suites if suite.suite_name == name]
|
||||
ordered.extend(suite for suite in suites if suite.suite_name not in priority)
|
||||
return ordered
|
||||
|
||||
|
||||
def filtered_suites_for_environment(suites: list[TestSelection]) -> list[TestSelection]:
|
||||
if os.environ.get("GITHUB_ACTIONS") != "true" or sys.platform != "darwin":
|
||||
return suites
|
||||
|
||||
# SwiftPM hangs before suite output for this executable-target suite on the Intel macOS runner.
|
||||
# Linux CI still runs it in the full Swift test lane, and local macOS runs it directly.
|
||||
skipped = {"CodexBarTests.CLIEntryTests"}
|
||||
filtered = [suite for suite in suites if suite.suite_name not in skipped]
|
||||
if len(filtered) != len(suites):
|
||||
print(f"Skipping macOS CI-only suites: {', '.join(sorted(skipped))}", flush=True)
|
||||
return filtered
|
||||
|
||||
|
||||
def filter_for(suites: list[TestSelection]) -> str:
|
||||
return rf"({'|'.join(suite.filter_pattern for suite in suites)})"
|
||||
|
||||
|
||||
def run_group(suites: list[TestSelection], timeout: int, swift_command: list[str]) -> int:
|
||||
return run_command(
|
||||
[*swift_command, "test", "--skip-build", "--no-parallel", "--filter", filter_for(suites)],
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def retry_selections_individually(
|
||||
suites: list[TestSelection],
|
||||
timeout: int,
|
||||
swift_command: list[str],
|
||||
stats: RunStats,
|
||||
) -> int:
|
||||
for suite in suites:
|
||||
stats.isolated_selection_retries += 1
|
||||
print(f"::group::Swift test retry {suite.name}", flush=True)
|
||||
retry_result = run_group([suite], timeout, swift_command)
|
||||
print("::endgroup::", flush=True)
|
||||
if retry_result != 0:
|
||||
return retry_result
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
total_started = time.monotonic()
|
||||
args = parse_args()
|
||||
stats = RunStats(
|
||||
group_size=args.group_size,
|
||||
shard_index=args.shard_index,
|
||||
shard_count=args.shard_count,
|
||||
)
|
||||
if args.group_size < 1:
|
||||
print("--group-size must be positive", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
swift_command = [args.swift_command, *args.swift_command_arg]
|
||||
result = 0
|
||||
try:
|
||||
discovery_started = time.monotonic()
|
||||
try:
|
||||
suites = prioritized_suites(filtered_suites_for_environment(swift_test_list(swift_command)))
|
||||
finally:
|
||||
stats.discovery_seconds = time.monotonic() - discovery_started
|
||||
stats.discovered_selections = len(suites)
|
||||
|
||||
suite_groups = list(chunks(suites, args.group_size))
|
||||
try:
|
||||
suite_groups = shard_groups(suite_groups, args.shard_index, args.shard_count)
|
||||
except ValueError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
result = 2
|
||||
return result
|
||||
if args.limit_groups is not None:
|
||||
suite_groups = suite_groups[: args.limit_groups]
|
||||
stats.selected_selections = sum(len(group) for group in suite_groups)
|
||||
stats.selected_groups = len(suite_groups)
|
||||
|
||||
shard_suffix = ""
|
||||
if args.shard_index is not None and args.shard_count is not None:
|
||||
shard_suffix = f" in shard {args.shard_index + 1}/{args.shard_count}"
|
||||
print(
|
||||
f"Discovered {len(suites)} test selections; running {stats.selected_selections} selections "
|
||||
f"in {len(suite_groups)} groups{shard_suffix}",
|
||||
flush=True,
|
||||
)
|
||||
if args.list_only:
|
||||
for group in suite_groups:
|
||||
for suite in group:
|
||||
print(suite.name)
|
||||
return 0
|
||||
|
||||
if not suite_groups:
|
||||
print("No test groups selected.", flush=True)
|
||||
return 0
|
||||
|
||||
execution_started = time.monotonic()
|
||||
for group_index, group in enumerate(suite_groups, start=1):
|
||||
print(
|
||||
f"::group::Swift test group {group_index}/{len(suite_groups)} "
|
||||
f"({len(group)} selections)",
|
||||
flush=True,
|
||||
)
|
||||
group_result = run_group(group, args.timeout, swift_command)
|
||||
print("::endgroup::", flush=True)
|
||||
if group_result == 0:
|
||||
stats.first_pass_successful_groups += 1
|
||||
continue
|
||||
|
||||
stats.first_pass_failed_groups += 1
|
||||
group_timed_out = group_result == 124
|
||||
if group_timed_out:
|
||||
stats.timed_out_groups += 1
|
||||
if len(group) == 1:
|
||||
result = group_result
|
||||
return result
|
||||
|
||||
if group_result != 124:
|
||||
if not args.retry_non_timeout_failures:
|
||||
result = group_result
|
||||
return result
|
||||
|
||||
stats.full_group_retries += 1
|
||||
print(f"Group {group_index} failed with exit code {group_result}; retrying group once", flush=True)
|
||||
retry_result = run_group(group, args.timeout, swift_command)
|
||||
if retry_result == 0:
|
||||
stats.recovered_groups += 1
|
||||
continue
|
||||
if retry_result != 124:
|
||||
result = retry_result
|
||||
return result
|
||||
group_timed_out = True
|
||||
stats.timed_out_groups += 1
|
||||
|
||||
print(f"Group {group_index} timed out; retrying selections one at a time", flush=True)
|
||||
retry_result = retry_selections_individually(group, args.timeout, swift_command, stats)
|
||||
if retry_result != 0:
|
||||
result = retry_result
|
||||
return result
|
||||
if group_timed_out:
|
||||
stats.recovered_groups += 1
|
||||
|
||||
return result
|
||||
finally:
|
||||
stats.total_seconds = time.monotonic() - total_started
|
||||
if "execution_started" in locals():
|
||||
stats.execution_seconds = time.monotonic() - execution_started
|
||||
if not args.list_only:
|
||||
print_timing_summary(stats)
|
||||
append_github_summary(stats)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
lint_result="${1:-}"
|
||||
changes_result="${2:-}"
|
||||
macos_tests_required="${3:-}"
|
||||
macos_test_result="${4:-}"
|
||||
|
||||
if [[ "$lint_result" != "success" ]]; then
|
||||
printf 'lint job finished with %s\n' "${lint_result:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$changes_result" != "success" ]]; then
|
||||
printf 'changes job finished with %s\n' "${changes_result:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${macos_tests_required}:${macos_test_result}" in
|
||||
true:success)
|
||||
printf 'Lint and macOS Swift test shards passed.\n'
|
||||
;;
|
||||
false:skipped)
|
||||
printf 'Lint passed; macOS Swift tests skipped for docs/site-only changes.\n'
|
||||
;;
|
||||
*)
|
||||
printf 'macOS test gate/result mismatch: required=%s result=%s\n' \
|
||||
"${macos_tests_required:-<empty>}" "${macos_test_result:-<empty>}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+326
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reset CodexBar: kill running instances, build, package, relaunch, verify.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_BUNDLE="${ROOT_DIR}/CodexBar.app"
|
||||
APP_PROCESS_PATTERN="CodexBar.app/Contents/MacOS/CodexBar"
|
||||
DEBUG_PROCESS_PATTERN="${ROOT_DIR}/.build/debug/CodexBar"
|
||||
RELEASE_PROCESS_PATTERN="${ROOT_DIR}/.build/release/CodexBar"
|
||||
LOCK_KEY="$(printf '%s' "${ROOT_DIR}" | shasum -a 256 | cut -c1-8)"
|
||||
LOCK_DIR="${TMPDIR:-/tmp}/codexbar-compile-and-run-${LOCK_KEY}"
|
||||
LOCK_PID_FILE="${LOCK_DIR}/pid"
|
||||
WAIT_FOR_LOCK=0
|
||||
RUN_TESTS=0
|
||||
DEBUG_LLDB=0
|
||||
RELEASE_ARCHES=""
|
||||
SIGNING_MODE="${CODEXBAR_SIGNING:-}"
|
||||
CLEAR_ADHOC_KEYCHAIN=0
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
delete_keychain_service_items() {
|
||||
local service="$1"
|
||||
security delete-generic-password -s "${service}" >/dev/null 2>&1 || true
|
||||
while security delete-generic-password -s "${service}" >/dev/null 2>&1; do
|
||||
:
|
||||
done
|
||||
}
|
||||
|
||||
# Ensure Swift >= 5.5 (required for --arch flag in swift build)
|
||||
ensure_swift_version() {
|
||||
local swift_output
|
||||
local swift_ver
|
||||
swift_output=$(swift --version 2>&1 || true)
|
||||
if [[ "$swift_output" =~ (Apple[[:space:]]+)?Swift[[:space:]]+version[[:space:]]+([0-9]+)\.([0-9]+)(\.[0-9]+)? ]]; then
|
||||
swift_ver="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}${BASH_REMATCH[4]}"
|
||||
else
|
||||
fail "Swift >= 5.5 required (found ${swift_output:-none}). Install Xcode or update swiftly."
|
||||
fi
|
||||
local major minor
|
||||
major=$(echo "$swift_ver" | cut -d. -f1)
|
||||
minor=$(echo "$swift_ver" | cut -d. -f2)
|
||||
if [[ "${major:-0}" -ge 6 ]] || { [[ "${major:-0}" -eq 5 ]] && [[ "${minor:-0}" -ge 5 ]]; }; then
|
||||
return 0
|
||||
fi
|
||||
# Try Xcode toolchain
|
||||
local xcrun_swift
|
||||
xcrun_swift=$(xcrun --find swift 2>/dev/null || true)
|
||||
if [[ -n "$xcrun_swift" && -x "$xcrun_swift" ]]; then
|
||||
log "WARN: PATH swift is v${swift_ver}; switching to Xcode toolchain at $(dirname "$xcrun_swift")"
|
||||
export PATH="$(dirname "$xcrun_swift"):$PATH"
|
||||
return 0
|
||||
fi
|
||||
fail "Swift >= 5.5 required (found ${swift_ver:-none}). Install Xcode or update swiftly."
|
||||
}
|
||||
|
||||
has_signing_identity() {
|
||||
local identity="${1:-}"
|
||||
if [[ -z "${identity}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
security find-identity -p codesigning -v 2>/dev/null | grep -F "${identity}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
detect_codesigning_identity() {
|
||||
local preferred_prefixes=(
|
||||
"Developer ID Application:"
|
||||
"Apple Development:"
|
||||
"Apple Distribution:"
|
||||
)
|
||||
local prefix
|
||||
local identities
|
||||
identities="$(security find-identity -p codesigning -v 2>/dev/null || true)"
|
||||
for prefix in "${preferred_prefixes[@]}"; do
|
||||
awk -v prefix="${prefix}" '
|
||||
index($0, "\"" prefix) {
|
||||
sub(/^[^\"]*\"/, "")
|
||||
sub(/\".*$/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' <<<"${identities}"
|
||||
done | sed -n '1p'
|
||||
}
|
||||
|
||||
export_team_id_from_identity() {
|
||||
local identity="${1:-}"
|
||||
if [[ -n "${APP_TEAM_ID:-}" || -z "${identity}" ]]; then
|
||||
return
|
||||
fi
|
||||
local subject
|
||||
subject="$(security find-certificate -c "${identity}" -p 2>/dev/null \
|
||||
| openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null || true)"
|
||||
if [[ "${subject}" =~ (^|,)OU=([A-Z0-9]{10})(,|$) ]]; then
|
||||
APP_TEAM_ID="${BASH_REMATCH[2]}"
|
||||
export APP_TEAM_ID
|
||||
return
|
||||
fi
|
||||
if [[ "${identity}" =~ \(([A-Z0-9]{10})\)$ ]]; then
|
||||
APP_TEAM_ID="${BASH_REMATCH[1]}"
|
||||
export APP_TEAM_ID
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_signing_mode() {
|
||||
if [[ -n "${SIGNING_MODE}" ]]; then
|
||||
export_team_id_from_identity "${APP_IDENTITY:-}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${APP_IDENTITY:-}" ]]; then
|
||||
if has_signing_identity "${APP_IDENTITY}"; then
|
||||
export_team_id_from_identity "${APP_IDENTITY}"
|
||||
SIGNING_MODE="identity"
|
||||
return
|
||||
fi
|
||||
log "WARN: APP_IDENTITY not found in Keychain; falling back to adhoc signing."
|
||||
SIGNING_MODE="adhoc"
|
||||
return
|
||||
fi
|
||||
|
||||
local candidate=""
|
||||
for candidate in \
|
||||
"Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
"CodexBar Development"
|
||||
do
|
||||
if has_signing_identity "${candidate}"; then
|
||||
APP_IDENTITY="${candidate}"
|
||||
export APP_IDENTITY
|
||||
export_team_id_from_identity "${APP_IDENTITY}"
|
||||
SIGNING_MODE="identity"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
candidate="$(detect_codesigning_identity)"
|
||||
if [[ -n "${candidate}" ]]; then
|
||||
APP_IDENTITY="${candidate}"
|
||||
export APP_IDENTITY
|
||||
export_team_id_from_identity "${APP_IDENTITY}"
|
||||
SIGNING_MODE="identity"
|
||||
return
|
||||
fi
|
||||
|
||||
SIGNING_MODE="adhoc"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local label="$1"; shift
|
||||
log "==> ${label}"
|
||||
if ! "$@"; then
|
||||
fail "${label} failed"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -d "${LOCK_DIR}" ]]; then
|
||||
rm -rf "${LOCK_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
acquire_lock() {
|
||||
while true; do
|
||||
if mkdir "${LOCK_DIR}" 2>/dev/null; then
|
||||
echo "$$" > "${LOCK_PID_FILE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local existing_pid=""
|
||||
if [[ -f "${LOCK_PID_FILE}" ]]; then
|
||||
existing_pid="$(cat "${LOCK_PID_FILE}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "${existing_pid}" ]] && kill -0 "${existing_pid}" 2>/dev/null; then
|
||||
if [[ "${WAIT_FOR_LOCK}" == "1" ]]; then
|
||||
log "==> Another agent is compiling (pid ${existing_pid}); waiting..."
|
||||
while kill -0 "${existing_pid}" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
continue
|
||||
fi
|
||||
log "==> Another agent is compiling (pid ${existing_pid}); re-run with --wait."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -rf "${LOCK_DIR}"
|
||||
done
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
kill_claude_probes() {
|
||||
# CodexBar spawns `claude /usage` + `/status` in a PTY; if we kill the app mid-probe we can orphan them.
|
||||
pkill -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true
|
||||
sleep 0.2
|
||||
pkill -9 -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true
|
||||
}
|
||||
|
||||
kill_all_codexbar() {
|
||||
is_running() {
|
||||
pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1 \
|
||||
|| pgrep -f "${DEBUG_PROCESS_PATTERN}" >/dev/null 2>&1 \
|
||||
|| pgrep -f "${RELEASE_PROCESS_PATTERN}" >/dev/null 2>&1 \
|
||||
|| pgrep -x "CodexBar" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Phase 1: request termination (give the app time to exit cleanly).
|
||||
for _ in {1..25}; do
|
||||
pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -x "CodexBar" 2>/dev/null || true
|
||||
if ! is_running; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# Phase 2: force kill any stragglers (avoids `open -n` creating multiple instances).
|
||||
pkill -9 -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -9 -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -9 -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
|
||||
pkill -9 -x "CodexBar" 2>/dev/null || true
|
||||
|
||||
for _ in {1..25}; do
|
||||
if ! is_running; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
fail "Failed to kill all CodexBar instances."
|
||||
}
|
||||
|
||||
# 1) Ensure a single runner instance.
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--wait|-w) WAIT_FOR_LOCK=1 ;;
|
||||
--test|-t) RUN_TESTS=1 ;;
|
||||
--debug-lldb) DEBUG_LLDB=1 ;;
|
||||
--clear-adhoc-keychain) CLEAR_ADHOC_KEYCHAIN=1 ;;
|
||||
--release-universal) RELEASE_ARCHES="arm64 x86_64" ;;
|
||||
--release-arches=*) RELEASE_ARCHES="${arg#*=}" ;;
|
||||
--help|-h)
|
||||
log "Usage: $(basename "$0") [--wait] [--test] [--debug-lldb] [--clear-adhoc-keychain] [--release-universal] [--release-arches=\"arm64 x86_64\"]"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_swift_version
|
||||
resolve_signing_mode
|
||||
if [[ "${CLEAR_ADHOC_KEYCHAIN}" == "1" && "${SIGNING_MODE}" != "adhoc" ]]; then
|
||||
fail "--clear-adhoc-keychain is only supported when using adhoc signing."
|
||||
fi
|
||||
if [[ "${SIGNING_MODE}" == "adhoc" ]]; then
|
||||
log "==> Signing: adhoc (set APP_IDENTITY or install a dev cert to avoid keychain prompts)"
|
||||
else
|
||||
log "==> Signing: ${APP_IDENTITY:-Developer ID Application}"
|
||||
fi
|
||||
|
||||
acquire_lock
|
||||
|
||||
# 2) Kill all running CodexBar instances (debug, release, bundled).
|
||||
log "==> Killing existing CodexBar instances"
|
||||
kill_all_codexbar
|
||||
kill_claude_probes
|
||||
|
||||
# 2.5) Optionally delete keychain entries to avoid permission prompts with adhoc signing
|
||||
# (adhoc signature changes on every build, making old keychain entries inaccessible)
|
||||
if [[ "${SIGNING_MODE:-adhoc}" == "adhoc" && "${CLEAR_ADHOC_KEYCHAIN}" == "1" ]]; then
|
||||
log "==> Clearing CodexBar keychain entries (adhoc signing)"
|
||||
# Clear both the legacy keychain store and the current cache service when developers explicitly want a clean reset
|
||||
# of CodexBar-owned keychain state for ad-hoc builds.
|
||||
delete_keychain_service_items "com.steipete.CodexBar"
|
||||
delete_keychain_service_items "com.steipete.codexbar.cache"
|
||||
elif [[ "${SIGNING_MODE:-adhoc}" == "adhoc" ]]; then
|
||||
log "==> Preserving CodexBar keychain entries (pass --clear-adhoc-keychain to reset adhoc keychain state)"
|
||||
fi
|
||||
|
||||
# 3) Package (release build happens inside package_app.sh).
|
||||
if [[ "${RUN_TESTS}" == "1" ]]; then
|
||||
run_step "sharded swift tests" "${ROOT_DIR}/Scripts/test.sh"
|
||||
fi
|
||||
if [[ "${DEBUG_LLDB}" == "1" && -n "${RELEASE_ARCHES}" ]]; then
|
||||
fail "--release-arches is only supported for release packaging"
|
||||
fi
|
||||
HOST_ARCH="$(uname -m)"
|
||||
ARCHES_VALUE="${HOST_ARCH}"
|
||||
if [[ -n "${RELEASE_ARCHES}" ]]; then
|
||||
ARCHES_VALUE="${RELEASE_ARCHES}"
|
||||
fi
|
||||
PACKAGE_ENV=(
|
||||
ARCHES="${ARCHES_VALUE}"
|
||||
)
|
||||
if [[ "${DEBUG_LLDB}" == "1" ]]; then
|
||||
run_step "package app" env CODEXBAR_ALLOW_LLDB=1 "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh" debug
|
||||
else
|
||||
if [[ -n "${SIGNING_MODE}" ]]; then
|
||||
run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh"
|
||||
else
|
||||
run_step "package app" env "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4) Launch the packaged app.
|
||||
log "==> launch app"
|
||||
if ! open "${APP_BUNDLE}"; then
|
||||
log "WARN: launch app returned non-zero; falling back to direct binary launch."
|
||||
"${APP_BUNDLE}/Contents/MacOS/CodexBar" >/dev/null 2>&1 &
|
||||
disown
|
||||
fi
|
||||
|
||||
# 5) Verify the app stays up for at least a moment (launch can be >1s on some systems).
|
||||
for _ in {1..10}; do
|
||||
if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then
|
||||
log "OK: CodexBar is running."
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.4
|
||||
done
|
||||
fail "App exited immediately. Check crash logs in Console.app (User Reports)."
|
||||
Executable
+289
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SurveyOptions {
|
||||
var root: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".codex", isDirectory: true)
|
||||
.appendingPathComponent("sessions", isDirectory: true)
|
||||
var days: Double = 30
|
||||
}
|
||||
|
||||
struct Survey {
|
||||
var files = 0
|
||||
var totalBytes: Int64 = 0
|
||||
var lines = 0
|
||||
var relevantLines = 0
|
||||
var lineLengths: [Int] = []
|
||||
var linesOver32KiB = 0
|
||||
var linesOver256KiB = 0
|
||||
var turnContextLines = 0
|
||||
var turnContextOver32KiB = 0
|
||||
var turnContextOver256KiB = 0
|
||||
var turnContextModelOffsets: [Int] = []
|
||||
var turnContextModelOffsetUnder32KiB = 0
|
||||
var turnContextModelOffsetUnder256KiB = 0
|
||||
var tokenCountLines = 0
|
||||
var tokenCountMissingExplicitModel = 0
|
||||
|
||||
mutating func recordFile(byteCount: Int64) {
|
||||
self.files += 1
|
||||
self.totalBytes += byteCount
|
||||
}
|
||||
|
||||
mutating func recordLine(_ line: Data) {
|
||||
guard !line.isEmpty else { return }
|
||||
|
||||
let length = line.count
|
||||
self.lines += 1
|
||||
self.lineLengths.append(length)
|
||||
if length > 32 * 1024 {
|
||||
self.linesOver32KiB += 1
|
||||
}
|
||||
if length > 256 * 1024 {
|
||||
self.linesOver256KiB += 1
|
||||
}
|
||||
|
||||
let isRelevant = line.contains(Marker.eventMessage)
|
||||
|| line.contains(Marker.turnContext)
|
||||
|| line.contains(Marker.sessionMetadata)
|
||||
if isRelevant {
|
||||
self.relevantLines += 1
|
||||
}
|
||||
|
||||
if line.contains(Marker.turnContext) {
|
||||
self.turnContextLines += 1
|
||||
if length > 32 * 1024 {
|
||||
self.turnContextOver32KiB += 1
|
||||
}
|
||||
if length > 256 * 1024 {
|
||||
self.turnContextOver256KiB += 1
|
||||
}
|
||||
if let offset = line.firstOffset(of: Marker.modelField)
|
||||
?? line.firstOffset(of: Marker.modelNameField)
|
||||
{
|
||||
self.turnContextModelOffsets.append(offset)
|
||||
if offset < 32 * 1024 {
|
||||
self.turnContextModelOffsetUnder32KiB += 1
|
||||
}
|
||||
if offset < 256 * 1024 {
|
||||
self.turnContextModelOffsetUnder256KiB += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if line.contains(Marker.tokenCount) {
|
||||
self.tokenCountLines += 1
|
||||
if !line.contains(Marker.modelField), !line.contains(Marker.modelNameField) {
|
||||
self.tokenCountMissingExplicitModel += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Marker {
|
||||
static let eventMessage = Data(#""type":"event_msg""#.utf8)
|
||||
static let turnContext = Data(#""type":"turn_context""#.utf8)
|
||||
static let sessionMetadata = Data(#""type":"session_meta""#.utf8)
|
||||
static let tokenCount = Data(#""token_count""#.utf8)
|
||||
static let modelField = Data(#""model""#.utf8)
|
||||
static let modelNameField = Data(#""model_name""#.utf8)
|
||||
}
|
||||
|
||||
extension Data {
|
||||
func contains(_ marker: Data) -> Bool {
|
||||
self.range(of: marker) != nil
|
||||
}
|
||||
|
||||
func firstOffset(of marker: Data) -> Int? {
|
||||
guard let range = self.range(of: marker) else { return nil }
|
||||
return self.distance(from: self.startIndex, to: range.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
func parseOptions(arguments: [String]) throws -> SurveyOptions {
|
||||
var options = SurveyOptions()
|
||||
var index = 1
|
||||
while index < arguments.count {
|
||||
switch arguments[index] {
|
||||
case "--root":
|
||||
index += 1
|
||||
guard index < arguments.count else {
|
||||
throw UsageError.message("--root requires a path")
|
||||
}
|
||||
options.root = URL(fileURLWithPath: expandTilde(arguments[index]), isDirectory: true)
|
||||
case "--days":
|
||||
index += 1
|
||||
guard index < arguments.count, let days = Double(arguments[index]) else {
|
||||
throw UsageError.message("--days requires a number")
|
||||
}
|
||||
options.days = days
|
||||
case "--help", "-h":
|
||||
printUsage()
|
||||
Foundation.exit(0)
|
||||
default:
|
||||
throw UsageError.message("unknown argument: \(arguments[index])")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func expandTilde(_ path: String) -> String {
|
||||
guard path == "~" || path.hasPrefix("~/") else { return path }
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
if path == "~" {
|
||||
return home
|
||||
}
|
||||
return home + String(path.dropFirst())
|
||||
}
|
||||
|
||||
enum UsageError: Error, CustomStringConvertible {
|
||||
case message(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case let .message(text):
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
print(
|
||||
"""
|
||||
Usage: Scripts/cost_jsonl_shape_survey.swift [--root PATH] [--days N]
|
||||
|
||||
Scans local Codex JSONL logs and prints aggregate shape only. It does not
|
||||
print prompts, tool payloads, model values, file paths, or raw log lines.
|
||||
""")
|
||||
}
|
||||
|
||||
func jsonlFiles(root: URL, modifiedSince cutoff: Date) -> [URL] {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]) else { return [] }
|
||||
|
||||
var files: [URL] = []
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard fileURL.pathExtension == "jsonl" else { continue }
|
||||
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey])
|
||||
guard values?.isRegularFile == true else { continue }
|
||||
guard let modifiedAt = values?.contentModificationDate, modifiedAt >= cutoff else { continue }
|
||||
files.append(fileURL)
|
||||
}
|
||||
return files.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
func validateRoot(_ root: URL) throws {
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: root.path, isDirectory: &isDirectory)
|
||||
guard exists, isDirectory.boolValue else {
|
||||
throw UsageError.message("root does not exist or is not a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func scan(fileURL: URL, into survey: inout Survey) throws {
|
||||
let values = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
survey.recordFile(byteCount: Int64(values.fileSize ?? 0))
|
||||
|
||||
let handle = try FileHandle(forReadingFrom: fileURL)
|
||||
defer { try? handle.close() }
|
||||
|
||||
var current = Data()
|
||||
current.reserveCapacity(4 * 1024)
|
||||
|
||||
while true {
|
||||
let chunk = try handle.read(upToCount: 256 * 1024) ?? Data()
|
||||
if chunk.isEmpty {
|
||||
survey.recordLine(current)
|
||||
break
|
||||
}
|
||||
|
||||
var segmentStart = chunk.startIndex
|
||||
while let newline = chunk[segmentStart...].firstIndex(of: 0x0A) {
|
||||
current.append(contentsOf: chunk[segmentStart..<newline])
|
||||
survey.recordLine(current)
|
||||
current.removeAll(keepingCapacity: true)
|
||||
segmentStart = chunk.index(after: newline)
|
||||
}
|
||||
|
||||
if segmentStart < chunk.endIndex {
|
||||
current.append(contentsOf: chunk[segmentStart..<chunk.endIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func percentile(_ values: [Int], _ percentile: Double) -> Int {
|
||||
guard !values.isEmpty else { return 0 }
|
||||
let sorted = values.sorted()
|
||||
let index = Int((percentile * Double(sorted.count - 1)).rounded())
|
||||
return sorted[max(0, min(index, sorted.count - 1))]
|
||||
}
|
||||
|
||||
func printSummary(_ survey: Survey, options: SurveyOptions) {
|
||||
print("root: \(redactedRootDescription(options.root))")
|
||||
print("window days: \(Int(options.days))")
|
||||
print("files: \(survey.files)")
|
||||
print("total bytes: \(survey.totalBytes)")
|
||||
print("lines: \(survey.lines)")
|
||||
print("relevant Codex scanner lines: \(survey.relevantLines)")
|
||||
print(
|
||||
"line length p50/p90/p95/p99/max: " +
|
||||
"\(percentile(survey.lineLengths, 0.50)) / " +
|
||||
"\(percentile(survey.lineLengths, 0.90)) / " +
|
||||
"\(percentile(survey.lineLengths, 0.95)) / " +
|
||||
"\(percentile(survey.lineLengths, 0.99)) / " +
|
||||
"\(percentile(survey.lineLengths, 1.00)) bytes")
|
||||
print("lines > 32 KiB: \(survey.linesOver32KiB)")
|
||||
print("lines > 256 KiB: \(survey.linesOver256KiB)")
|
||||
print("turn_context lines: \(survey.turnContextLines)")
|
||||
print("turn_context lines > 32 KiB: \(survey.turnContextOver32KiB)")
|
||||
print("turn_context lines > 256 KiB: \(survey.turnContextOver256KiB)")
|
||||
print(
|
||||
"turn_context model offset p50/p95/max: " +
|
||||
"\(percentile(survey.turnContextModelOffsets, 0.50)) / " +
|
||||
"\(percentile(survey.turnContextModelOffsets, 0.95)) / " +
|
||||
"\(percentile(survey.turnContextModelOffsets, 1.00)) bytes")
|
||||
print(
|
||||
"turn_context model offset < 32 KiB: " +
|
||||
"\(survey.turnContextModelOffsetUnder32KiB) / \(survey.turnContextLines)")
|
||||
print(
|
||||
"turn_context model offset < 256 KiB: " +
|
||||
"\(survey.turnContextModelOffsetUnder256KiB) / \(survey.turnContextLines)")
|
||||
print(
|
||||
"token_count rows missing an explicit model: " +
|
||||
"\(survey.tokenCountMissingExplicitModel) / \(survey.tokenCountLines)")
|
||||
}
|
||||
|
||||
func redactedRootDescription(_ root: URL) -> String {
|
||||
let defaultRoot = SurveyOptions().root.standardizedFileURL.path
|
||||
let currentRoot = root.standardizedFileURL.path
|
||||
if currentRoot == defaultRoot {
|
||||
return "default Codex sessions"
|
||||
}
|
||||
return "custom root (redacted)"
|
||||
}
|
||||
|
||||
do {
|
||||
let options = try parseOptions(arguments: CommandLine.arguments)
|
||||
try validateRoot(options.root)
|
||||
let cutoff = Date().addingTimeInterval(-options.days * 24 * 60 * 60)
|
||||
let files = jsonlFiles(root: options.root, modifiedSince: cutoff)
|
||||
guard !files.isEmpty else {
|
||||
throw UsageError.message("no .jsonl files found in the selected time window")
|
||||
}
|
||||
var survey = Survey()
|
||||
for fileURL in files {
|
||||
try scan(fileURL: fileURL, into: &survey)
|
||||
}
|
||||
printSummary(survey, options: options)
|
||||
} catch let error as UsageError {
|
||||
fputs("error: \(error.description)\n\n", stderr)
|
||||
printUsage()
|
||||
Foundation.exit(2)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
Foundation.exit(1)
|
||||
}
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DOCS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'docs');
|
||||
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
||||
|
||||
function walkMarkdownFiles(dir, base = dir) {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
||||
files.push(...walkMarkdownFiles(fullPath, base));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
files.push(relative(base, fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function extractMetadata(fullPath) {
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
|
||||
if (!content.startsWith('---')) {
|
||||
return { summary: null, readWhen: [], error: 'missing front matter' };
|
||||
}
|
||||
|
||||
const endIndex = content.indexOf('\n---', 3);
|
||||
if (endIndex === -1) {
|
||||
return { summary: null, readWhen: [], error: 'unterminated front matter' };
|
||||
}
|
||||
|
||||
const frontMatter = content.slice(3, endIndex).trim();
|
||||
const lines = frontMatter.split('\n');
|
||||
|
||||
let summaryLine = null;
|
||||
const readWhen = [];
|
||||
let collectingReadWhen = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('summary:')) {
|
||||
summaryLine = line;
|
||||
collectingReadWhen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('read_when:')) {
|
||||
collectingReadWhen = true;
|
||||
const inline = line.slice('read_when:'.length).trim();
|
||||
if (inline.startsWith('[') && inline.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(inline.replace(/'/g, '"'));
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean)
|
||||
.forEach((v) => readWhen.push(v));
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed inline */
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collectingReadWhen) {
|
||||
if (line.startsWith('- ')) {
|
||||
const hint = line.slice(2).trim();
|
||||
if (hint) readWhen.push(hint);
|
||||
} else if (line === '') {
|
||||
// allow blank spacer lines inside list
|
||||
} else {
|
||||
collectingReadWhen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!summaryLine) {
|
||||
return { summary: null, readWhen, error: 'summary key missing' };
|
||||
}
|
||||
|
||||
const summaryValue = summaryLine.slice('summary:'.length).trim();
|
||||
const normalized = summaryValue.replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return { summary: null, readWhen, error: 'summary is empty' };
|
||||
}
|
||||
|
||||
return { summary: normalized, readWhen };
|
||||
}
|
||||
|
||||
console.log('Listing all markdown files in docs folder:');
|
||||
|
||||
const markdownFiles = walkMarkdownFiles(DOCS_DIR);
|
||||
|
||||
for (const relativePath of markdownFiles) {
|
||||
const fullPath = join(DOCS_DIR, relativePath);
|
||||
const { summary, readWhen, error } = extractMetadata(fullPath);
|
||||
if (summary) {
|
||||
console.log(`${relativePath} - ${summary}`);
|
||||
if (readWhen.length > 0) {
|
||||
console.log(` Read when: ${readWhen.join('; ')}`);
|
||||
}
|
||||
} else {
|
||||
const reason = error ? ` - [${error}]` : '';
|
||||
console.log(`${relativePath}${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.');
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const docsDir = path.join(repoRoot, "docs");
|
||||
const args = process.argv.slice(2);
|
||||
const mode = parseMode(args);
|
||||
const cname = fs.readFileSync(path.join(docsDir, "CNAME"), "utf8").trim();
|
||||
const origin = "https://" + cname;
|
||||
const productName = "CodexBar";
|
||||
const source = "https://github.com/steipete/CodexBar";
|
||||
const outputPath = path.join(docsDir, "llms.txt");
|
||||
|
||||
const pages = allHtml(docsDir)
|
||||
.map((file) => {
|
||||
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
|
||||
if (rel === "404.html" || rel === "social.html") return null;
|
||||
const html = fs.readFileSync(file, "utf8");
|
||||
return {
|
||||
rel,
|
||||
title: textContent(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]) || titleize(path.basename(rel, ".html")),
|
||||
description: attr(html.match(/<meta\s+name=["']description["']\s+content=["']([^"']*)["'][^>]*>/i)?.[1] || ""),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.rel === "index.html" ? -1 : b.rel === "index.html" ? 1 : a.rel.localeCompare(b.rel)));
|
||||
const productDescription =
|
||||
pages.find((page) => page.rel === "index.html")?.description ||
|
||||
"CodexBar shows AI coding-provider usage limits in the macOS menu bar.";
|
||||
|
||||
const lines = [
|
||||
"# " + productName,
|
||||
"",
|
||||
productDescription,
|
||||
"",
|
||||
"Canonical documentation:",
|
||||
...pages.map((page) => "- " + page.title + ": " + pageUrl(page.rel) + (page.description ? " - " + page.description : "")),
|
||||
"",
|
||||
"Source: " + source,
|
||||
"",
|
||||
"Guidance for agents:",
|
||||
"- Prefer the canonical documentation URLs above over README excerpts or package metadata.",
|
||||
"- Fetch only the pages needed for the current task; this is an index, not a full-site corpus.",
|
||||
"",
|
||||
];
|
||||
const output = lines.join("\n");
|
||||
|
||||
if (mode === "check") {
|
||||
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : null;
|
||||
if (current !== output) {
|
||||
console.error(`${path.relative(repoRoot, outputPath)} is out of date; run node Scripts/generate-llms.mjs`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("llms index OK: " + path.relative(repoRoot, outputPath));
|
||||
} else {
|
||||
fs.writeFileSync(outputPath, output, "utf8");
|
||||
console.log("wrote " + path.relative(repoRoot, outputPath));
|
||||
}
|
||||
|
||||
function parseMode(values) {
|
||||
if (values.length === 0) return "write";
|
||||
if (values.length === 1 && (values[0] === "write" || values[0] === "--write")) return "write";
|
||||
if (values.length === 1 && (values[0] === "check" || values[0] === "--check")) return "check";
|
||||
console.error("Usage: node Scripts/generate-llms.mjs [write|--write|check|--check]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function allHtml(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.name === "node_modules" || entry.name.startsWith(".")) return [];
|
||||
if (entry.isDirectory()) return allHtml(full);
|
||||
return entry.name.endsWith(".html") ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function pageUrl(rel) {
|
||||
return rel === "index.html" ? origin + "/" : origin + "/" + rel;
|
||||
}
|
||||
|
||||
function textContent(value) {
|
||||
return attr(value || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function attr(value) {
|
||||
return String(value || "")
|
||||
.replace(/—/g, "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function titleize(input) {
|
||||
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TOOLS_DIR="${ROOT_DIR}/.build/lint-tools"
|
||||
BIN_DIR="${TOOLS_DIR}/bin"
|
||||
|
||||
SWIFTFORMAT_VERSION="0.61.1"
|
||||
SWIFTLINT_VERSION="0.65.0"
|
||||
|
||||
SWIFTFORMAT_SHA256_DARWIN="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
|
||||
SWIFTLINT_SHA256_DARWIN="d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6"
|
||||
SWIFTFORMAT_SHA256_LINUX_X86_64="7bc8706e3fd51963f1f29eb99098ebdf482f3497fa527c68e6cf75cbee29c77a"
|
||||
SWIFTLINT_SHA256_LINUX_X86_64="79306a34e5c7cc55a220cd108cbb861dcad5f10138dcdf261e2624ae8b0a486b"
|
||||
SWIFTFORMAT_SHA256_LINUX_ARM64="42a35b557a6d56975fba3a48e78d39ab5388c8faac65d4819f25d3e20c7504c0"
|
||||
SWIFTLINT_SHA256_LINUX_ARM64="12d3b84bc5b69ae13a99a5a5c79904f9ce25867f099f6368d0037854f9ee6c26"
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
INSTALL_SWIFTFORMAT=false
|
||||
INSTALL_SWIFTLINT=false
|
||||
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
INSTALL_SWIFTFORMAT=true
|
||||
INSTALL_SWIFTLINT=true
|
||||
else
|
||||
for tool in "$@"; do
|
||||
case "$tool" in
|
||||
all)
|
||||
INSTALL_SWIFTFORMAT=true
|
||||
INSTALL_SWIFTLINT=true
|
||||
;;
|
||||
swiftformat)
|
||||
INSTALL_SWIFTFORMAT=true
|
||||
;;
|
||||
swiftlint)
|
||||
INSTALL_SWIFTLINT=true
|
||||
;;
|
||||
*)
|
||||
fail "Unknown lint tool '${tool}'. Usage: $(basename "$0") [all|swiftformat|swiftlint]..."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
sha256_value() {
|
||||
local path="$1"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$path" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$path" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
fail "Missing shasum/sha256sum."
|
||||
}
|
||||
|
||||
download_file() {
|
||||
local url="$1"
|
||||
local out="$2"
|
||||
curl -fL --retry 3 --retry-connrefused --retry-delay 2 -o "$out" "$url"
|
||||
}
|
||||
|
||||
install_zip_binary() {
|
||||
local label="$1"
|
||||
local url="$2"
|
||||
local expected_sha="$3"
|
||||
local binary_name="$4"
|
||||
local installed_name="${5:-$binary_name}"
|
||||
|
||||
local tmp_zip
|
||||
tmp_zip="$(mktemp -t "${label}.XXXX")"
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d -t "${label}.XXXX")"
|
||||
|
||||
log "==> Downloading ${label}"
|
||||
download_file "$url" "$tmp_zip"
|
||||
|
||||
local actual_sha
|
||||
actual_sha="$(sha256_value "$tmp_zip")"
|
||||
if [[ -n "$expected_sha" && "$actual_sha" != "$expected_sha" ]]; then
|
||||
rm -f "$tmp_zip"
|
||||
rm -rf "$tmp_dir"
|
||||
fail "${label} SHA256 mismatch (expected ${expected_sha}, got ${actual_sha})"
|
||||
fi
|
||||
|
||||
unzip -q "$tmp_zip" -d "$tmp_dir"
|
||||
|
||||
local extracted_path=""
|
||||
if [[ -f "${tmp_dir}/${binary_name}" ]]; then
|
||||
extracted_path="${tmp_dir}/${binary_name}"
|
||||
else
|
||||
extracted_path="$(find "$tmp_dir" -type f -name "$binary_name" | head -n 1 || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$extracted_path" || ! -f "$extracted_path" ]]; then
|
||||
rm -f "$tmp_zip"
|
||||
rm -rf "$tmp_dir"
|
||||
fail "${label} binary '${binary_name}' not found in archive"
|
||||
fi
|
||||
|
||||
install -m 0755 "$extracted_path" "${BIN_DIR}/${installed_name}"
|
||||
|
||||
rm -f "$tmp_zip"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
swiftformat_installed() {
|
||||
[[ -x "${BIN_DIR}/swiftformat" ]] \
|
||||
&& [[ "$("${BIN_DIR}/swiftformat" --version 2>/dev/null || true)" == "${SWIFTFORMAT_VERSION}" ]]
|
||||
}
|
||||
|
||||
swiftlint_installed() {
|
||||
[[ -x "${BIN_DIR}/swiftlint" ]] \
|
||||
&& [[ "$("${BIN_DIR}/swiftlint" version 2>/dev/null || true)" == "${SWIFTLINT_VERSION}" ]]
|
||||
}
|
||||
|
||||
if { [[ "$INSTALL_SWIFTFORMAT" != true ]] || swiftformat_installed; } \
|
||||
&& { [[ "$INSTALL_SWIFTLINT" != true ]] || swiftlint_installed; }
|
||||
then
|
||||
log "==> Requested lint tools already installed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
case "$OS" in
|
||||
Darwin)
|
||||
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip"
|
||||
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip"
|
||||
|
||||
if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then
|
||||
install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256_DARWIN" "swiftformat"
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then
|
||||
install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256_DARWIN" "swiftlint"
|
||||
fi
|
||||
;;
|
||||
Linux)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux.zip"
|
||||
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_amd64.zip"
|
||||
SWIFTFORMAT_BINARY="swiftformat_linux"
|
||||
SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_X86_64"
|
||||
SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_X86_64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux_aarch64.zip"
|
||||
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_arm64.zip"
|
||||
SWIFTFORMAT_BINARY="swiftformat_linux_aarch64"
|
||||
SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_ARM64"
|
||||
SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_ARM64"
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported Linux arch: ${ARCH}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if { [[ "$INSTALL_SWIFTFORMAT" == true ]] && [[ -z "$SWIFTFORMAT_SHA256" ]]; } \
|
||||
|| { [[ "$INSTALL_SWIFTLINT" == true ]] && [[ -z "$SWIFTLINT_SHA256" ]]; }
|
||||
then
|
||||
log "WARN: Linux SHA256 verification not configured for ${ARCH}; installing anyway."
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then
|
||||
install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256" "$SWIFTFORMAT_BINARY" "swiftformat"
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then
|
||||
install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256" "swiftlint"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported OS: ${OS}"
|
||||
;;
|
||||
esac
|
||||
|
||||
log "==> Installed lint tools to ${BIN_DIR}"
|
||||
if [[ "$INSTALL_SWIFTFORMAT" == true ]]; then
|
||||
"${BIN_DIR}/swiftformat" --version
|
||||
fi
|
||||
if [[ "$INSTALL_SWIFTLINT" == true ]]; then
|
||||
"${BIN_DIR}/swiftlint" version
|
||||
fi
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Simple script to launch CodexBar (kills existing instance first)
|
||||
# Usage: ./Scripts/launch.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_PATH="$PROJECT_ROOT/CodexBar.app"
|
||||
|
||||
echo "==> Killing existing CodexBar instances"
|
||||
pkill -x CodexBar || pkill -f CodexBar.app || true
|
||||
sleep 0.5
|
||||
|
||||
if [[ ! -d "$APP_PATH" ]]; then
|
||||
echo "ERROR: CodexBar.app not found at $APP_PATH"
|
||||
echo "Run ./Scripts/package_app.sh first to build the app"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Launching CodexBar from $APP_PATH"
|
||||
open -n "$APP_PATH"
|
||||
|
||||
# Wait a moment and check if it's running
|
||||
sleep 1
|
||||
if pgrep -x CodexBar > /dev/null; then
|
||||
echo "OK: CodexBar is running."
|
||||
else
|
||||
echo "ERROR: App exited immediately. Check crash logs in Console.app (User Reports)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN_DIR="${ROOT_DIR}/.build/lint-tools/bin"
|
||||
|
||||
ensure_swiftformat() {
|
||||
"${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftformat
|
||||
}
|
||||
|
||||
ensure_swiftlint() {
|
||||
"${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftlint
|
||||
}
|
||||
|
||||
check_codex_parser_hash() {
|
||||
"${ROOT_DIR}/Scripts/regenerate-codex-parser-hash.sh" --check
|
||||
}
|
||||
|
||||
check_package_product_paths() {
|
||||
"${ROOT_DIR}/Scripts/test_package_product_paths.sh"
|
||||
}
|
||||
|
||||
check_package_strip() {
|
||||
"${ROOT_DIR}/Scripts/test_package_strip.sh"
|
||||
}
|
||||
|
||||
check_package_signing() {
|
||||
"${ROOT_DIR}/Scripts/test_package_signing.sh"
|
||||
}
|
||||
|
||||
check_release_dsym_paths() {
|
||||
"${ROOT_DIR}/Scripts/test_release_dsym_paths.sh"
|
||||
}
|
||||
|
||||
check_sparkle_signing_paths() {
|
||||
"${ROOT_DIR}/Scripts/test_sparkle_signing_paths.sh"
|
||||
}
|
||||
|
||||
check_swift_test_sharding() {
|
||||
"${ROOT_DIR}/Scripts/test_swift_test_sharding.sh"
|
||||
}
|
||||
|
||||
check_ci_path_gate() {
|
||||
"${ROOT_DIR}/Scripts/test_ci_path_gate.sh"
|
||||
}
|
||||
|
||||
check_repository_size() {
|
||||
"${ROOT_DIR}/Scripts/check_repository_size.sh"
|
||||
"${ROOT_DIR}/Scripts/test_repository_size.sh"
|
||||
}
|
||||
|
||||
check_shell_scripts() {
|
||||
local count=0
|
||||
local script
|
||||
for script in "${ROOT_DIR}"/Scripts/*.sh "${ROOT_DIR}"/Scripts/mac-release; do
|
||||
[[ -f "$script" ]] || continue
|
||||
bash -n "$script"
|
||||
count=$((count + 1))
|
||||
done
|
||||
printf 'shell scripts OK: %d files\n' "$count"
|
||||
}
|
||||
|
||||
check_app_locales() {
|
||||
node "${ROOT_DIR}/Scripts/check-app-locales.mjs" --test
|
||||
node "${ROOT_DIR}/Scripts/check-app-locales.mjs"
|
||||
}
|
||||
|
||||
check_site_locales() {
|
||||
node "${ROOT_DIR}/Scripts/check-site-locales.mjs"
|
||||
node --check "${ROOT_DIR}/docs/site.js"
|
||||
}
|
||||
|
||||
check_documentation_links() {
|
||||
node "${ROOT_DIR}/Scripts/check-documentation-links.mjs"
|
||||
}
|
||||
|
||||
check_llms_index() {
|
||||
node "${ROOT_DIR}/Scripts/generate-llms.mjs" --check
|
||||
}
|
||||
|
||||
run_portable_checks() {
|
||||
check_codex_parser_hash
|
||||
check_package_product_paths
|
||||
check_package_strip
|
||||
check_package_signing
|
||||
check_release_dsym_paths
|
||||
check_sparkle_signing_paths
|
||||
check_swift_test_sharding
|
||||
check_ci_path_gate
|
||||
check_repository_size
|
||||
check_shell_scripts
|
||||
check_documentation_links
|
||||
check_llms_index
|
||||
check_site_locales
|
||||
}
|
||||
|
||||
run_swiftformat_lint() {
|
||||
ensure_swiftformat
|
||||
"${BIN_DIR}/swiftformat" Sources Tests --lint
|
||||
}
|
||||
|
||||
run_swiftlint() {
|
||||
ensure_swiftlint
|
||||
"${BIN_DIR}/swiftlint" --strict
|
||||
}
|
||||
|
||||
cmd="${1:-lint}"
|
||||
|
||||
case "$cmd" in
|
||||
lint)
|
||||
check_app_locales
|
||||
run_portable_checks
|
||||
run_swiftformat_lint
|
||||
run_swiftlint
|
||||
;;
|
||||
lint-linux)
|
||||
run_portable_checks
|
||||
run_swiftlint
|
||||
;;
|
||||
lint-macos)
|
||||
check_app_locales
|
||||
run_swiftformat_lint
|
||||
;;
|
||||
format)
|
||||
ensure_swiftformat
|
||||
"${BIN_DIR}/swiftformat" Sources Tests
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: %s [lint|lint-linux|lint-macos|format]\n' "$(basename "$0")" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -n "${MAC_RELEASE_TOOL:-}" ]]; then
|
||||
exec "$MAC_RELEASE_TOOL" "$@"
|
||||
fi
|
||||
|
||||
for candidate in \
|
||||
"$ROOT/../agent-scripts/skills/release-mac-app/scripts/mac-release" \
|
||||
"$HOME/Projects/agent-scripts/skills/release-mac-app/scripts/mac-release"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
exec "$candidate" "$@"
|
||||
fi
|
||||
done
|
||||
|
||||
cat >&2 <<'EOF'
|
||||
Missing mac-release helper.
|
||||
Clone agent-scripts next to this repo or set MAC_RELEASE_TOOL=/path/to/mac-release.
|
||||
EOF
|
||||
exit 127
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" make-appcast "$@"
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mimo-usage — local token usage tracker for cc-mimo
|
||||
|
||||
Scans ~/.claude-envs/mimo/.claude/projects/**/*.jsonl session files,
|
||||
sums input/output/cache tokens per time window (today/week/all),
|
||||
writes to ~/.codexbar/mimo-local-usage.json, and prints a human-readable
|
||||
summary by default.
|
||||
|
||||
Usage:
|
||||
mimo-usage # show summary (also refreshes cache)
|
||||
mimo-usage --update # refresh cache only, no output (for LaunchAgent/wrapper)
|
||||
mimo-usage --json # JSON output
|
||||
mimo-usage --short # 1-line status (for status line / widget)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
MIMO_HOME = Path(os.environ.get("MIMO_CLAUDE_HOME", Path.home() / ".claude-envs" / "mimo")).expanduser()
|
||||
PROJECTS_DIR = MIMO_HOME / ".claude" / "projects"
|
||||
CACHE_PATH = Path(
|
||||
os.environ.get("MIMO_LOCAL_USAGE_PATH", Path.home() / ".codexbar" / "mimo-local-usage.json")
|
||||
).expanduser()
|
||||
|
||||
|
||||
def parse_session_usage(jsonl_path: Path):
|
||||
"""Yield (identity, timestamp_iso, usage_dict) for each assistant message with usage."""
|
||||
try:
|
||||
with jsonl_path.open() as f:
|
||||
for line in f:
|
||||
try:
|
||||
d = json.loads(line)
|
||||
ts = d.get("timestamp")
|
||||
msg = d.get("message")
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
usage = msg.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
continue
|
||||
if not ts:
|
||||
continue
|
||||
metadata = d.get("metadata")
|
||||
message_metadata = msg.get("metadata")
|
||||
session_id = d.get("sessionId") or d.get("session_id")
|
||||
if not session_id and isinstance(metadata, dict):
|
||||
session_id = metadata.get("sessionId")
|
||||
if not session_id and isinstance(message_metadata, dict):
|
||||
session_id = message_metadata.get("sessionId")
|
||||
message_id = msg.get("id")
|
||||
request_id = d.get("requestId") or d.get("request_id")
|
||||
identity = None
|
||||
if all(isinstance(value, str) and value for value in (message_id, request_id)):
|
||||
identity = ("request", message_id, request_id)
|
||||
elif (
|
||||
request_id is None
|
||||
and isinstance(session_id, str)
|
||||
and session_id
|
||||
and isinstance(message_id, str)
|
||||
and message_id
|
||||
):
|
||||
identity = ("legacy", session_id, message_id)
|
||||
yield identity, ts, usage
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
except (OSError, IOError):
|
||||
return
|
||||
|
||||
|
||||
def aggregate_usage():
|
||||
"""Scan all mimo session jsonls and return windowed token sums."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# Week starts on Monday 00:00 UTC
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
|
||||
windows = {
|
||||
"today": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
|
||||
"week": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
|
||||
"all_time": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
|
||||
}
|
||||
sessions_scanned = 0
|
||||
last_activity = None
|
||||
keyed_rows = {}
|
||||
unkeyed_rows = []
|
||||
|
||||
if not PROJECTS_DIR.exists():
|
||||
return windows, sessions_scanned, last_activity
|
||||
|
||||
for jsonl in PROJECTS_DIR.rglob("*.jsonl"):
|
||||
sessions_scanned += 1
|
||||
for identity, ts_str, usage in parse_session_usage(jsonl):
|
||||
try:
|
||||
# Parse ISO timestamp (may end with Z)
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
row = (ts, usage)
|
||||
if identity is None:
|
||||
unkeyed_rows.append(row)
|
||||
else:
|
||||
previous = keyed_rows.get(identity)
|
||||
if previous is None or ts >= previous[0]:
|
||||
keyed_rows[identity] = row
|
||||
|
||||
for ts, usage in [*keyed_rows.values(), *unkeyed_rows]:
|
||||
input_t = int(usage.get("input_tokens", 0) or 0)
|
||||
output_t = int(usage.get("output_tokens", 0) or 0)
|
||||
cache_read_t = int(usage.get("cache_read_input_tokens", 0) or 0)
|
||||
cache_create_t = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
||||
|
||||
if last_activity is None or ts > last_activity:
|
||||
last_activity = ts
|
||||
|
||||
# all_time
|
||||
w = windows["all_time"]
|
||||
w["input"] += input_t
|
||||
w["output"] += output_t
|
||||
w["cache_read"] += cache_read_t
|
||||
w["cache_create"] += cache_create_t
|
||||
w["messages"] += 1
|
||||
|
||||
if ts >= week_start:
|
||||
w = windows["week"]
|
||||
w["input"] += input_t
|
||||
w["output"] += output_t
|
||||
w["cache_read"] += cache_read_t
|
||||
w["cache_create"] += cache_create_t
|
||||
w["messages"] += 1
|
||||
|
||||
if ts >= today_start:
|
||||
w = windows["today"]
|
||||
w["input"] += input_t
|
||||
w["output"] += output_t
|
||||
w["cache_read"] += cache_read_t
|
||||
w["cache_create"] += cache_create_t
|
||||
w["messages"] += 1
|
||||
|
||||
return windows, sessions_scanned, last_activity
|
||||
|
||||
|
||||
def write_cache(windows, sessions_scanned, last_activity):
|
||||
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_activity": last_activity.isoformat() if last_activity else None,
|
||||
"sessions_scanned": sessions_scanned,
|
||||
"windows": windows,
|
||||
"source": "local-jsonl-scan",
|
||||
"note": "Local token accounting from cc-mimo session jsonl. Not a quota; mimo platform.xiaomimimo.com SSO cookie required for real quota.",
|
||||
}
|
||||
tmp = CACHE_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2))
|
||||
tmp.replace(CACHE_PATH)
|
||||
return payload
|
||||
|
||||
|
||||
def fmt_tokens(n: int) -> str:
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.1f}k"
|
||||
return str(n)
|
||||
|
||||
|
||||
def short_status(payload):
|
||||
"""1-line status line."""
|
||||
w = payload["windows"]["week"]
|
||||
total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"]
|
||||
return f"mimo: {fmt_tokens(total)} tok this week ({w['messages']} msg)"
|
||||
|
||||
|
||||
def human_summary(payload):
|
||||
"""Multi-line human-readable summary."""
|
||||
last = payload.get("last_activity")
|
||||
if last:
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last)
|
||||
ago = datetime.now(timezone.utc) - last_dt
|
||||
if ago.total_seconds() < 60:
|
||||
ago_str = "just now"
|
||||
elif ago.total_seconds() < 3600:
|
||||
ago_str = f"{int(ago.total_seconds() / 60)}m ago"
|
||||
elif ago.total_seconds() < 86400:
|
||||
ago_str = f"{int(ago.total_seconds() / 3600)}h ago"
|
||||
else:
|
||||
ago_str = f"{ago.days}d ago"
|
||||
except (ValueError, TypeError):
|
||||
ago_str = last
|
||||
else:
|
||||
ago_str = "never"
|
||||
|
||||
lines = [
|
||||
"== MiMo (local tracker) ==",
|
||||
f"Sessions scanned: {payload['sessions_scanned']}",
|
||||
f"Last activity: {ago_str}",
|
||||
"",
|
||||
]
|
||||
for window_name, label in [("today", "Today"), ("week", "This week"), ("all_time", "All time")]:
|
||||
w = payload["windows"][window_name]
|
||||
in_t = fmt_tokens(w["input"])
|
||||
out_t = fmt_tokens(w["output"])
|
||||
cr_t = fmt_tokens(w["cache_read"])
|
||||
cc_t = fmt_tokens(w["cache_create"])
|
||||
total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"]
|
||||
lines.append(f"{label:>10}: {fmt_tokens(total):>8} total | in={in_t} out={out_t} cache_r={cr_t} cache_c={cc_t} | msg={w['messages']}")
|
||||
lines.append("")
|
||||
lines.append("Note: this is local accounting from cc-mimo session jsonl.")
|
||||
lines.append("Real platform quota requires Chrome cookie (cookieSource=manual).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
quiet = "--update" in args
|
||||
json_out = "--json" in args
|
||||
short = "--short" in args
|
||||
|
||||
windows, sessions_scanned, last_activity = aggregate_usage()
|
||||
payload = write_cache(windows, sessions_scanned, last_activity)
|
||||
|
||||
if quiet:
|
||||
return 0
|
||||
if json_out:
|
||||
print(json.dumps(payload, indent=2))
|
||||
return 0
|
||||
if short:
|
||||
print(short_status(payload))
|
||||
return 0
|
||||
|
||||
print(human_summary(payload))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+541
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
resolve_package_signing_mode() {
|
||||
local requested="${CODEXBAR_SIGNING:-adhoc}"
|
||||
case "$requested" in
|
||||
adhoc|identity) ;;
|
||||
*)
|
||||
echo "ERROR: Unsupported CODEXBAR_SIGNING: $requested (expected adhoc or identity)" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
SIGNING_MODE="$requested"
|
||||
}
|
||||
|
||||
CONF=${1:-release}
|
||||
ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0}
|
||||
SIGNING_MODE=
|
||||
resolve_package_signing_mode
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]')
|
||||
case "$LOWER_CONF" in
|
||||
debug|release) ;;
|
||||
*)
|
||||
echo "ERROR: Unsupported build configuration: $CONF (expected debug or release)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Load version info
|
||||
source "$ROOT/version.env"
|
||||
source "$ROOT/Scripts/package_product_paths.sh"
|
||||
source "$ROOT/Scripts/sparkle_signing_paths.sh"
|
||||
|
||||
# Clean build only when explicitly requested (slower).
|
||||
if [[ "${CODEXBAR_FORCE_CLEAN:-0}" == "1" ]]; then
|
||||
if [[ -d "$ROOT/.build" ]]; then
|
||||
if command -v trash >/dev/null 2>&1; then
|
||||
if ! trash "$ROOT/.build"; then
|
||||
echo "WARN: trash .build failed; continuing with swift package clean." >&2
|
||||
fi
|
||||
else
|
||||
rm -rf "$ROOT/.build" || echo "WARN: rm -rf .build failed; continuing with swift package clean." >&2
|
||||
fi
|
||||
fi
|
||||
swift package clean >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Build for host architecture by default; allow overriding via ARCHES (e.g., "arm64 x86_64" for universal).
|
||||
ARCH_LIST=( ${ARCHES:-} )
|
||||
if [[ ${#ARCH_LIST[@]} -eq 0 ]]; then
|
||||
HOST_ARCH=$(uname -m)
|
||||
case "$HOST_ARCH" in
|
||||
arm64) ARCH_LIST=(arm64) ;;
|
||||
x86_64) ARCH_LIST=(x86_64) ;;
|
||||
*) ARCH_LIST=("$HOST_ARCH") ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
patch_keyboard_shortcuts() {
|
||||
local util_path="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift"
|
||||
if [[ ! -f "$util_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if grep -q "keyboardShortcutsSafeBundle" "$util_path"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
chmod +w "$util_path" || true
|
||||
python3 - "$util_path" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
if ".keyboardShortcutsSafeBundle" in text:
|
||||
sys.exit(0)
|
||||
|
||||
text = text.replace(
|
||||
'NSLocalizedString(self, bundle: .module, comment: self)',
|
||||
'NSLocalizedString(self, bundle: .keyboardShortcutsSafeBundle, comment: self)',
|
||||
)
|
||||
|
||||
inject = """
|
||||
private extension Bundle {
|
||||
/// Safe lookup that avoids the fatal trap in the autogenerated `Bundle.module`
|
||||
/// when the resource bundle is not placed at the bundle root.
|
||||
static let keyboardShortcutsSafeBundle: Bundle = {
|
||||
#if os(macOS)
|
||||
if let url = Bundle.main.url(forResource: "KeyboardShortcuts_KeyboardShortcuts", withExtension: "bundle"),
|
||||
let bundle = Bundle(url: url) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
let rootURL = Bundle.main.bundleURL.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle")
|
||||
if let bundle = Bundle(url: rootURL) {
|
||||
return bundle
|
||||
}
|
||||
#endif
|
||||
|
||||
let devURL = URL(fileURLWithPath: #file)
|
||||
.deletingLastPathComponent() // Utilities.swift
|
||||
.deletingLastPathComponent() // KeyboardShortcuts
|
||||
.deletingLastPathComponent() // Sources
|
||||
.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle")
|
||||
if let bundle = Bundle(url: devURL) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
return Bundle.main
|
||||
}()
|
||||
}
|
||||
"""
|
||||
|
||||
marker = "}\n\n\nextension Data {"
|
||||
if marker not in text:
|
||||
raise SystemExit("Marker not found in Utilities.swift; patch failed.")
|
||||
|
||||
text = text.replace(marker, "}\n\n" + inject + "\n\nextension Data {")
|
||||
path.write_text(text)
|
||||
PY
|
||||
}
|
||||
|
||||
KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift"
|
||||
if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then
|
||||
swift build -c "$CONF" --arch "${ARCH_LIST[0]}"
|
||||
fi
|
||||
patch_keyboard_shortcuts
|
||||
|
||||
# Resolve SwiftPM's current output path without relying on a fixed build-system layout.
|
||||
# The output variable keeps the per-arch cache in this shell instead of losing it to
|
||||
# command substitution.
|
||||
swiftpm_bin_path() {
|
||||
local arch="$1"
|
||||
local output_var="$2"
|
||||
local cache_var="SWIFTPM_BIN_PATH_${arch//[^A-Za-z0-9]/_}"
|
||||
if [[ -z "${!cache_var+set}" ]]; then
|
||||
local resolved
|
||||
if ! resolved=$(codexbar_swiftpm_bin_path "$CONF" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
printf -v "$cache_var" '%s' "$resolved"
|
||||
fi
|
||||
printf -v "$output_var" '%s' "${!cache_var}"
|
||||
}
|
||||
|
||||
binary_has_arch() {
|
||||
local binary="$1"
|
||||
local arch="$2"
|
||||
[[ -f "$binary" ]] && lipo -archs "$binary" 2>/dev/null | tr ' ' '\n' | grep -qx "$arch"
|
||||
}
|
||||
|
||||
# SwiftBuild can reuse one output directory for sequential per-arch builds. Snapshot
|
||||
# each fresh slice before the next build can replace it.
|
||||
PRODUCT_STAGE_ROOT="$ROOT/.build/package-products/$LOWER_CONF"
|
||||
rm -rf "$PRODUCT_STAGE_ROOT"
|
||||
|
||||
stage_build_products() {
|
||||
local arch="$1"
|
||||
local bin_dir stage_dir name product
|
||||
swiftpm_bin_path "$arch" bin_dir
|
||||
|
||||
stage_dir="$PRODUCT_STAGE_ROOT/$arch"
|
||||
mkdir -p "$stage_dir"
|
||||
for name in CodexBar CodexBarCLI CodexBarClaudeWatchdog; do
|
||||
if ! product=$(codexbar_require_product_file "$bin_dir" "$name" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if ! binary_has_arch "$product" "$arch"; then
|
||||
echo "ERROR: ${product} does not contain required architecture: ${arch}" >&2
|
||||
return 1
|
||||
fi
|
||||
cp "$product" "$stage_dir/$name"
|
||||
done
|
||||
if [[ -d "$bin_dir/CodexBar.dSYM" ]]; then
|
||||
cp -R "$bin_dir/CodexBar.dSYM" "$stage_dir/"
|
||||
fi
|
||||
}
|
||||
|
||||
for ARCH in "${ARCH_LIST[@]}"; do
|
||||
swift build -c "$CONF" --arch "$ARCH"
|
||||
stage_build_products "$ARCH"
|
||||
done
|
||||
|
||||
APP_FINAL="$ROOT/CodexBar.app"
|
||||
APP_STAGE="$ROOT/.build/package/CodexBar.app"
|
||||
rm -rf "$APP_STAGE"
|
||||
APP="$APP_STAGE"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks"
|
||||
mkdir -p "$APP/Contents/Helpers" "$APP/Contents/PlugIns"
|
||||
|
||||
# Convert new .icon bundle to .icns if present (macOS 14+/IconStudio export)
|
||||
ICON_SOURCE="$ROOT/Icon.icon"
|
||||
ICON_TARGET="$ROOT/Icon.icns"
|
||||
if [[ -f "$ICON_SOURCE" ]]; then
|
||||
iconutil --convert icns --output "$ICON_TARGET" "$ICON_SOURCE"
|
||||
fi
|
||||
|
||||
BUNDLE_ID="com.steipete.codexbar"
|
||||
FEED_URL="https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml"
|
||||
AUTO_CHECKS=true
|
||||
if [[ "$LOWER_CONF" == "debug" ]]; then
|
||||
BUNDLE_ID="com.steipete.codexbar.debug"
|
||||
FEED_URL=""
|
||||
AUTO_CHECKS=false
|
||||
fi
|
||||
if [[ "$SIGNING_MODE" == "adhoc" ]]; then
|
||||
FEED_URL=""
|
||||
AUTO_CHECKS=false
|
||||
fi
|
||||
WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget"
|
||||
APP_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}"
|
||||
APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar"
|
||||
if [[ "$BUNDLE_ID" == *".debug"* ]]; then
|
||||
APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug"
|
||||
fi
|
||||
ENTITLEMENTS_DIR="$ROOT/.build/entitlements"
|
||||
APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements"
|
||||
WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements"
|
||||
mkdir -p "$ENTITLEMENTS_DIR"
|
||||
if [[ "$ALLOW_LLDB" == "1" && "$LOWER_CONF" != "debug" ]]; then
|
||||
echo "ERROR: CODEXBAR_ALLOW_LLDB requires debug configuration" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat > "$APP_ENTITLEMENTS" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</array>
|
||||
$(if [[ "$ALLOW_LLDB" == "1" ]]; then echo " <key>com.apple.security.get-task-allow</key><true/>"; fi)
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
cat > "$WIDGET_ENTITLEMENTS" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>${APP_GROUP_ID}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
cat > "$APP/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>CodexBar</string>
|
||||
<key>CFBundleDisplayName</key><string>CodexBar</string>
|
||||
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
||||
<key>CFBundleExecutable</key><string>CodexBar</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>${MARKETING_VERSION}</string>
|
||||
<key>CFBundleVersion</key><string>${BUILD_NUMBER}</string>
|
||||
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||
<key>LSUIElement</key><true/>
|
||||
<key>CFBundleIconFile</key><string>Icon</string>
|
||||
<key>NSHumanReadableCopyright</key><string>© 2026 Peter Steinberger. MIT License.</string>
|
||||
<key>SUFeedURL</key><string>${FEED_URL}</string>
|
||||
<key>SUPublicEDKey</key><string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
|
||||
<key>SUEnableAutomaticChecks</key><${AUTO_CHECKS}/>
|
||||
<key>CodexBuildTimestamp</key><string>${BUILD_TIMESTAMP}</string>
|
||||
<key>CodexGitCommit</key><string>${GIT_COMMIT}</string>
|
||||
<key>CodexBarTeamID</key><string>${APP_TEAM_ID}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
# Resolve a built binary from the fresh per-arch snapshot or SwiftPM's reported directory.
|
||||
resolve_binary_path() {
|
||||
local name="$1"
|
||||
local arch="$2"
|
||||
local bin_dir candidate
|
||||
swiftpm_bin_path "$arch" bin_dir
|
||||
if ! candidate=$(codexbar_resolve_staged_or_reported_file \
|
||||
"$PRODUCT_STAGE_ROOT" "$bin_dir" "$name" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if ! binary_has_arch "$candidate" "$arch"; then
|
||||
echo "ERROR: ${candidate} does not contain required architecture: ${arch}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$candidate"
|
||||
}
|
||||
|
||||
verify_binary_arches() {
|
||||
local binary="$1"; shift
|
||||
local expected=("$@")
|
||||
local actual
|
||||
actual=$(lipo -archs "$binary")
|
||||
local actual_count expected_count
|
||||
actual_count=$(wc -w <<<"$actual" | tr -d ' ')
|
||||
expected_count=${#expected[@]}
|
||||
if [[ "$actual_count" -ne "$expected_count" ]]; then
|
||||
echo "ERROR: $binary arch mismatch (expected: ${expected[*]}, actual: ${actual})" >&2
|
||||
exit 1
|
||||
fi
|
||||
for arch in "${expected[@]}"; do
|
||||
if [[ "$actual" != *"$arch"* ]]; then
|
||||
echo "ERROR: $binary missing arch $arch (have: ${actual})" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
local name="$1"
|
||||
local dest="$2"
|
||||
local binaries=()
|
||||
for arch in "${ARCH_LIST[@]}"; do
|
||||
local src
|
||||
if ! src=$(resolve_binary_path "$name" "$arch"); then
|
||||
exit 1
|
||||
fi
|
||||
binaries+=("$src")
|
||||
done
|
||||
if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then
|
||||
lipo -create "${binaries[@]}" -output "$dest"
|
||||
else
|
||||
cp "${binaries[0]}" "$dest"
|
||||
fi
|
||||
chmod +x "$dest"
|
||||
verify_binary_arches "$dest" "${ARCH_LIST[@]}"
|
||||
}
|
||||
|
||||
strip_release_binary() {
|
||||
local binary="$1"
|
||||
if [[ "$LOWER_CONF" != "release" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -f "$binary" ]]; then
|
||||
return 0
|
||||
fi
|
||||
xcrun strip -x "$binary"
|
||||
}
|
||||
|
||||
ensure_widget_extension_project() {
|
||||
local spec="$ROOT/WidgetExtension/project.yml"
|
||||
local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj"
|
||||
if [[ -f "$project_dir/project.pbxproj" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v xcodegen >/dev/null 2>&1; then
|
||||
echo "ERROR: Missing ${project_dir}; install xcodegen or restore the generated project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The tracked project is authoritative. Regenerating it during packaging records the checkout
|
||||
# directory's spelling in a package file reference and leaves release worktrees dirty.
|
||||
xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet
|
||||
}
|
||||
|
||||
build_widget_extension() {
|
||||
local xcode_conf="Release"
|
||||
if [[ "$LOWER_CONF" == "debug" ]]; then
|
||||
xcode_conf="Debug"
|
||||
fi
|
||||
|
||||
ensure_widget_extension_project
|
||||
|
||||
local derived_dir="$ROOT/.build/xcode-widget-extension-${LOWER_CONF}"
|
||||
local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj"
|
||||
local build_log="$derived_dir/xcodebuild.log"
|
||||
local timeout_seconds="${CODEXBAR_WIDGET_EXTENSION_TIMEOUT_SECONDS:-900}"
|
||||
local archs="${ARCH_LIST[*]}"
|
||||
|
||||
mkdir -p "$derived_dir"
|
||||
echo "Building CodexBarWidget Xcode extension (${xcode_conf}, ${archs})." >&2
|
||||
xcodebuild \
|
||||
-project "$project_dir" \
|
||||
-scheme CodexBarWidgetExtension \
|
||||
-configuration "$xcode_conf" \
|
||||
-destination "generic/platform=macOS" \
|
||||
-derivedDataPath "$derived_dir" \
|
||||
-skipPackageUpdates \
|
||||
-disableAutomaticPackageResolution \
|
||||
-skipMacroValidation \
|
||||
-skipPackagePluginValidation \
|
||||
CODEXBAR_WIDGET_BUNDLE_ID="$WIDGET_BUNDLE_ID" \
|
||||
CODEXBAR_TEAM_ID="$APP_TEAM_ID" \
|
||||
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
ARCHS="$archs" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
build >"$build_log" 2>&1 &
|
||||
|
||||
local xcodebuild_pid=$!
|
||||
local elapsed=0
|
||||
while kill -0 "$xcodebuild_pid" 2>/dev/null; do
|
||||
if [[ "$elapsed" -ge "$timeout_seconds" ]]; then
|
||||
kill "$xcodebuild_pid" 2>/dev/null || true
|
||||
wait "$xcodebuild_pid" 2>/dev/null || true
|
||||
tail -80 "$build_log" >&2 || true
|
||||
echo "ERROR: Timed out building CodexBarWidget extension after ${timeout_seconds}s" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
if (( elapsed > 0 && elapsed % 60 == 0 )); then
|
||||
echo "Still building CodexBarWidget extension (${elapsed}s)..." >&2
|
||||
fi
|
||||
done
|
||||
if ! wait "$xcodebuild_pid"; then
|
||||
tail -120 "$build_log" >&2 || true
|
||||
echo "ERROR: Failed to build CodexBarWidget extension" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local appex="$derived_dir/Build/Products/${xcode_conf}/CodexBarWidget.appex"
|
||||
if [[ ! -f "$appex/Contents/MacOS/CodexBarWidget" ]]; then
|
||||
echo "ERROR: Missing Xcode-built CodexBarWidget.appex at ${appex}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$appex"
|
||||
}
|
||||
|
||||
install_widget_extension() {
|
||||
local src_appex
|
||||
src_appex="$(build_widget_extension)"
|
||||
local widget_app="$APP/Contents/PlugIns/CodexBarWidget.appex"
|
||||
rm -rf "$widget_app"
|
||||
mkdir -p "$APP/Contents/PlugIns"
|
||||
cp -R "$src_appex" "$widget_app"
|
||||
verify_binary_arches "$widget_app/Contents/MacOS/CodexBarWidget" "${ARCH_LIST[@]}"
|
||||
}
|
||||
|
||||
install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar"
|
||||
strip_release_binary "$APP/Contents/MacOS/CodexBar"
|
||||
# Ship CodexBarCLI alongside the app for easy symlinking.
|
||||
install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI"
|
||||
strip_release_binary "$APP/Contents/Helpers/CodexBarCLI"
|
||||
# Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed.
|
||||
install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
install_widget_extension
|
||||
strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
|
||||
|
||||
swiftpm_bin_path "${ARCH_LIST[0]}" PREFERRED_BUILD_DIR
|
||||
|
||||
# Embed Sparkle.framework
|
||||
SPARKLE_SOURCE=$(codexbar_require_product_directory "$PREFERRED_BUILD_DIR" Sparkle.framework packaging)
|
||||
cp -R "$SPARKLE_SOURCE" "$APP/Contents/Frameworks/"
|
||||
chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework"
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar"
|
||||
# Re-sign Sparkle and all nested components with the selected package identity.
|
||||
SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
|
||||
if [[ "$SIGNING_MODE" == "adhoc" ]]; then
|
||||
CODESIGN_ID="-"
|
||||
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
|
||||
elif [[ "$ALLOW_LLDB" == "1" ]]; then
|
||||
CODESIGN_ID="-"
|
||||
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
|
||||
else
|
||||
CODESIGN_ID="${APP_IDENTITY:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}"
|
||||
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID")
|
||||
fi
|
||||
function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }
|
||||
# Validate Sparkle's nested layout before signing so framework layout drift fails clearly.
|
||||
SPARKLE_SIGNING_TARGETS=$(codexbar_sparkle_signing_targets "$SPARKLE")
|
||||
while IFS= read -r SPARKLE_TARGET; do
|
||||
resign "$SPARKLE_TARGET"
|
||||
done <<<"$SPARKLE_SIGNING_TARGETS"
|
||||
|
||||
if [[ -f "$ICON_TARGET" ]]; then
|
||||
cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns"
|
||||
fi
|
||||
|
||||
# Bundle app resources (provider icons, etc.).
|
||||
APP_RESOURCES_DIR="$ROOT/Sources/CodexBar/Resources"
|
||||
if [[ -d "$APP_RESOURCES_DIR" ]]; then
|
||||
cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/"
|
||||
fi
|
||||
if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then
|
||||
echo "ERROR: Missing Icon-classic.icns in app bundle resources." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SwiftPM resource bundles (e.g. KeyboardShortcuts) are emitted next to the built binary.
|
||||
shopt -s nullglob
|
||||
SWIFTPM_BUNDLES=("${PREFERRED_BUILD_DIR}/"*.bundle)
|
||||
shopt -u nullglob
|
||||
if [[ ${#SWIFTPM_BUNDLES[@]} -gt 0 ]]; then
|
||||
for bundle in "${SWIFTPM_BUNDLES[@]}"; do
|
||||
bundle_name="$(basename "$bundle")"
|
||||
cp -R "$bundle" "$APP/Contents/Resources/"
|
||||
done
|
||||
fi
|
||||
if [[ ! -d "$APP/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle" ]]; then
|
||||
echo "ERROR: Missing KeyboardShortcuts SwiftPM resource bundle (Settings → Keyboard shortcut will crash)." >&2
|
||||
echo "Expected: ${PREFERRED_BUILD_DIR}/KeyboardShortcuts_KeyboardShortcuts.bundle" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure contents are writable before stripping attributes and signing.
|
||||
chmod -R u+w "$APP"
|
||||
|
||||
# Strip extended attributes to prevent AppleDouble (._*) files that break code sealing
|
||||
xattr -cr "$APP"
|
||||
find "$APP" -name '._*' -delete
|
||||
|
||||
# Sign helper binaries if present
|
||||
if [[ -f "${APP}/Contents/Helpers/CodexBarCLI" ]]; then
|
||||
codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarCLI"
|
||||
fi
|
||||
if [[ -f "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then
|
||||
codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
fi
|
||||
|
||||
# Sign widget extension if present
|
||||
if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then
|
||||
codesign "${CODESIGN_ARGS[@]}" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
|
||||
codesign "${CODESIGN_ARGS[@]}" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP/Contents/PlugIns/CodexBarWidget.appex"
|
||||
fi
|
||||
|
||||
# Finally sign the app bundle itself
|
||||
codesign "${CODESIGN_ARGS[@]}" \
|
||||
--entitlements "$APP_ENTITLEMENTS" \
|
||||
"$APP"
|
||||
|
||||
rm -rf "$APP_FINAL"
|
||||
mv "$APP" "$APP_FINAL"
|
||||
APP="$APP_FINAL"
|
||||
echo "Created $APP"
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_swiftpm_bin_path() {
|
||||
local conf="$1"
|
||||
shift
|
||||
local command=(swift build --show-bin-path -c "$conf")
|
||||
local arch
|
||||
for arch in "$@"; do
|
||||
command+=(--arch "$arch")
|
||||
done
|
||||
|
||||
local path
|
||||
if ! path=$("${command[@]}"); then
|
||||
echo "ERROR: SwiftPM failed to report the ${conf} product directory for: $*" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$path" ]]; then
|
||||
echo "ERROR: SwiftPM reported an empty ${conf} product directory for: $*" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
codexbar_require_product_file() {
|
||||
local bin_dir="$1"
|
||||
local name="$2"
|
||||
local arch_label="$3"
|
||||
local product="$bin_dir/$name"
|
||||
if [[ ! -f "$product" ]]; then
|
||||
echo "ERROR: Missing ${name} for ${arch_label} at SwiftPM-reported path: ${product}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$product"
|
||||
}
|
||||
|
||||
codexbar_require_product_directory() {
|
||||
local bin_dir="$1"
|
||||
local name="$2"
|
||||
local context="$3"
|
||||
local product="$bin_dir/$name"
|
||||
if [[ ! -d "$product" ]]; then
|
||||
echo "ERROR: Missing ${name} for ${context} at SwiftPM-reported path: ${product}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$product"
|
||||
}
|
||||
|
||||
codexbar_resolve_staged_or_reported_file() {
|
||||
local stage_root="$1"
|
||||
local bin_dir="$2"
|
||||
local name="$3"
|
||||
local arch="$4"
|
||||
local staged="$stage_root/$arch/$name"
|
||||
if [[ -f "$staged" ]]; then
|
||||
printf '%s\n' "$staged"
|
||||
return
|
||||
fi
|
||||
codexbar_require_product_file "$bin_dir" "$name" "$arch"
|
||||
}
|
||||
|
||||
codexbar_resolve_dsym_path() {
|
||||
local stage_root="$1"
|
||||
local bin_dir="$2"
|
||||
local app_name="$3"
|
||||
local arch="$4"
|
||||
local staged="$stage_root/$arch/${app_name}.dSYM"
|
||||
if [[ -d "$staged" ]]; then
|
||||
printf '%s\n' "$staged"
|
||||
return
|
||||
fi
|
||||
codexbar_require_product_directory "$bin_dir" "${app_name}.dSYM" "$arch"
|
||||
}
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# Prepare a clean branch for upstream PR submission
|
||||
# Usage: ./Scripts/prepare_upstream_pr.sh <feature-name>
|
||||
|
||||
set -e
|
||||
|
||||
FEATURE_NAME=$1
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [ -z "$FEATURE_NAME" ]; then
|
||||
echo -e "${RED}Error: Feature name required${NC}"
|
||||
echo "Usage: ./Scripts/prepare_upstream_pr.sh <feature-name>"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./Scripts/prepare_upstream_pr.sh fix-cursor-bonus"
|
||||
echo " ./Scripts/prepare_upstream_pr.sh improve-cookie-handling"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRANCH_NAME="upstream-pr/$FEATURE_NAME"
|
||||
|
||||
echo -e "${BLUE}==> Fetching latest upstream...${NC}"
|
||||
git fetch upstream
|
||||
|
||||
echo -e "${BLUE}==> Creating upstream PR branch from upstream/main...${NC}"
|
||||
git checkout upstream/main
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> Branch created: $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️ IMPORTANT: This branch is for UPSTREAM submission${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Guidelines for upstream PRs:${NC}"
|
||||
echo ""
|
||||
echo "✅ DO include:"
|
||||
echo " - Bug fixes that affect all users"
|
||||
echo " - Performance improvements"
|
||||
echo " - Provider enhancements (generic)"
|
||||
echo " - Documentation improvements"
|
||||
echo " - Test coverage"
|
||||
echo ""
|
||||
echo "❌ DO NOT include:"
|
||||
echo " - Fork branding (About.swift, PreferencesAboutPane.swift)"
|
||||
echo " - Fork-specific features (multi-account, etc.)"
|
||||
echo " - References to topoffunnel.com"
|
||||
echo " - Experimental features"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. Cherry-pick your commits (clean, no fork branding):"
|
||||
echo " ${GREEN}git cherry-pick <commit-hash>${NC}"
|
||||
echo ""
|
||||
echo "2. Or manually apply changes:"
|
||||
echo " ${GREEN}# Edit files${NC}"
|
||||
echo " ${GREEN}git add <files>${NC}"
|
||||
echo " ${GREEN}git commit -m 'fix: description'${NC}"
|
||||
echo ""
|
||||
echo "3. Ensure tests pass:"
|
||||
echo " ${GREEN}make test${NC}"
|
||||
echo ""
|
||||
echo "4. Review changes:"
|
||||
echo " ${GREEN}git diff upstream/main${NC}"
|
||||
echo ""
|
||||
echo "5. Push to your fork:"
|
||||
echo " ${GREEN}git push origin $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo "6. Create PR on GitHub:"
|
||||
echo " ${GREEN}https://github.com/steipete/CodexBar/compare/main...topoffunnel:$BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Remember: Keep PRs small and focused for better merge chances!${NC}"
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SOURCE_DIR="${ROOT_DIR}/Sources/CodexBarCore/Vendored/CostUsage"
|
||||
OUTPUT_FILE="${ROOT_DIR}/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift"
|
||||
MODE="${1:-write}"
|
||||
|
||||
case "$MODE" in
|
||||
write | --write)
|
||||
CHECK_ONLY=0
|
||||
;;
|
||||
check | --check)
|
||||
CHECK_ONLY=1
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [write|--write|check|--check]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
HASH_CMD=(shasum -a 256)
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
HASH_CMD=(sha256sum)
|
||||
else
|
||||
echo "error: shasum or sha256sum is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE_LIST="$(mktemp)"
|
||||
trap 'rm -f "$FILE_LIST"' EXIT
|
||||
|
||||
find "$SOURCE_DIR" \
|
||||
-type f \
|
||||
-name '*.swift' \
|
||||
! -name '*Claude*' \
|
||||
-print |
|
||||
sed "s#^${ROOT_DIR}/##" |
|
||||
LC_ALL=C sort >"$FILE_LIST"
|
||||
|
||||
HASH="$(
|
||||
while IFS= read -r file; do
|
||||
printf '== %s ==\n' "$file"
|
||||
cat "${ROOT_DIR}/${file}"
|
||||
printf '\n'
|
||||
done <"$FILE_LIST" | "${HASH_CMD[@]}"
|
||||
)"
|
||||
HASH="${HASH%% *}"
|
||||
SHORT_HASH="${HASH:0:16}"
|
||||
|
||||
render_generated() {
|
||||
cat <<SWIFT
|
||||
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.
|
||||
|
||||
enum CodexParserHash {
|
||||
static let value = "$SHORT_HASH"
|
||||
}
|
||||
SWIFT
|
||||
}
|
||||
|
||||
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
||||
EXPECTED_FILE="$(mktemp)"
|
||||
trap 'rm -f "$FILE_LIST" "$EXPECTED_FILE"' EXIT
|
||||
render_generated >"$EXPECTED_FILE"
|
||||
if ! cmp -s "$EXPECTED_FILE" "$OUTPUT_FILE"; then
|
||||
echo "error: ${OUTPUT_FILE#${ROOT_DIR}/} is stale. Run Scripts/regenerate-codex-parser-hash.sh and commit the result." >&2
|
||||
if [[ -f "$OUTPUT_FILE" ]]; then
|
||||
diff -u "$OUTPUT_FILE" "$EXPECTED_FILE" >&2 || true
|
||||
else
|
||||
diff -u /dev/null "$EXPECTED_FILE" >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "Codex parser hash is current (${SHORT_HASH})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||
render_generated >"$OUTPUT_FILE"
|
||||
|
||||
echo "Updated ${OUTPUT_FILE#${ROOT_DIR}/} to ${SHORT_HASH}"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" release "$@"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_release_arch_label() {
|
||||
local raw="${1:-arm64 x86_64}"
|
||||
local normalized
|
||||
local has_arm64=0
|
||||
local has_x86_64=0
|
||||
local arch
|
||||
|
||||
normalized=$(printf "%s" "$raw" | tr ',' ' ')
|
||||
for arch in $normalized; do
|
||||
case "$arch" in
|
||||
arm64) has_arm64=1 ;;
|
||||
x86_64) has_x86_64=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$has_arm64" == "1" && "$has_x86_64" == "1" ]]; then
|
||||
printf "macos-universal"
|
||||
return
|
||||
fi
|
||||
if [[ "$has_arm64" == "1" ]]; then
|
||||
printf "macos-arm64"
|
||||
return
|
||||
fi
|
||||
if [[ "$has_x86_64" == "1" ]]; then
|
||||
printf "macos-x86_64"
|
||||
return
|
||||
fi
|
||||
|
||||
printf "macos-%s" "$(printf "%s" "$normalized" | tr ' ' '+')"
|
||||
}
|
||||
|
||||
codexbar_app_zip_name() {
|
||||
local version=$1
|
||||
local arches="${2:-arm64 x86_64}"
|
||||
printf "CodexBar-%s-%s.zip" "$(codexbar_release_arch_label "$arches")" "$version"
|
||||
}
|
||||
|
||||
codexbar_dsym_zip_name() {
|
||||
local version=$1
|
||||
local arches="${2:-arm64 x86_64}"
|
||||
printf "CodexBar-%s-%s.dSYM.zip" "$(codexbar_release_arch_label "$arches")" "$version"
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_dsym_dwarf_path() {
|
||||
local dsym_path="$1"
|
||||
local app_name="$2"
|
||||
local dwarf_path="${dsym_path}/Contents/Resources/DWARF/${app_name}"
|
||||
|
||||
if [[ ! -f "$dwarf_path" ]]; then
|
||||
echo "Missing fresh dSYM for ${app_name} at: ${dwarf_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$dwarf_path"
|
||||
}
|
||||
|
||||
codexbar_require_dsym_dwarf_for_arch() {
|
||||
local dsym_path="$1"
|
||||
local app_name="$2"
|
||||
local arch="$3"
|
||||
local dwarf_path
|
||||
|
||||
if ! dwarf_path=$(codexbar_dsym_dwarf_path "$dsym_path" "$app_name"); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! lipo -archs "$dwarf_path" | tr ' ' '\n' | grep -qx "$arch"; then
|
||||
echo "dSYM at ${dwarf_path} does not contain required architecture: ${arch}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$dwarf_path"
|
||||
}
|
||||
|
||||
codexbar_dwarf_uuid_for_arch() {
|
||||
local path="$1"
|
||||
local arch="$2"
|
||||
local uuid
|
||||
|
||||
uuid=$(dwarfdump --uuid "$path" | awk -v arch="(${arch})" '$1 == "UUID:" && $3 == arch { print $2; exit }')
|
||||
if [[ -z "$uuid" ]]; then
|
||||
echo "Missing UUID for ${arch} in: ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$uuid"
|
||||
}
|
||||
|
||||
codexbar_verify_dsym_matches_binary() {
|
||||
local app_binary="$1"
|
||||
local dsym_dwarf="$2"
|
||||
shift 2
|
||||
local arch app_uuid dsym_uuid
|
||||
|
||||
if [[ ! -f "$app_binary" ]]; then
|
||||
echo "Missing app binary for dSYM UUID verification: ${app_binary}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "$dsym_dwarf" ]]; then
|
||||
echo "Missing dSYM DWARF file for UUID verification: ${dsym_dwarf}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
for arch in "$@"; do
|
||||
if ! app_uuid=$(codexbar_dwarf_uuid_for_arch "$app_binary" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if ! dsym_uuid=$(codexbar_dwarf_uuid_for_arch "$dsym_dwarf" "$arch"); then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$app_uuid" != "$dsym_uuid" ]]; then
|
||||
echo "dSYM UUID mismatch for ${arch}: app=${app_uuid}, dSYM=${dsym_uuid}" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a review branch for upstream changes
|
||||
# Usage: ./Scripts/review_upstream.sh [upstream|quotio]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
UPSTREAM=${1:-upstream}
|
||||
DATE=$(date +%Y%m%d)
|
||||
BRANCH_NAME="upstream-sync/${UPSTREAM}-${DATE}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [ "$UPSTREAM" != "upstream" ] && [ "$UPSTREAM" != "quotio" ]; then
|
||||
echo -e "${RED}Error: Must specify 'upstream' or 'quotio'${NC}"
|
||||
echo "Usage: ./Scripts/review_upstream.sh [upstream|quotio]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_remote() {
|
||||
local remote=$1
|
||||
local url=$2
|
||||
local origin_url
|
||||
|
||||
if git remote get-url "$remote" >/dev/null 2>&1; then
|
||||
echo "$remote"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$remote" = "upstream" ] && git remote get-url origin >/dev/null 2>&1; then
|
||||
origin_url=$(git remote get-url origin)
|
||||
case "$origin_url" in
|
||||
https://github.com/steipete/CodexBar|https://github.com/steipete/CodexBar.git|git@github.com:steipete/CodexBar.git)
|
||||
echo -e "${YELLOW}Remote 'upstream' missing; using origin for steipete/CodexBar.${NC}" >&2
|
||||
echo "origin"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${YELLOW}Remote 'upstream' missing; origin is not steipete/CodexBar, adding upstream.${NC}" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Adding $remote remote...${NC}" >&2
|
||||
git remote add "$remote" "$url"
|
||||
echo "$remote"
|
||||
}
|
||||
|
||||
remote_default_branch() {
|
||||
local remote=$1
|
||||
local branch=""
|
||||
local candidate
|
||||
|
||||
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
|
||||
if [ -z "$branch" ]; then
|
||||
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
|
||||
fi
|
||||
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
|
||||
echo "$branch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in main master; do
|
||||
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$UPSTREAM" in
|
||||
upstream) REMOTE=$(ensure_remote upstream "https://github.com/steipete/CodexBar.git") ;;
|
||||
quotio) REMOTE=$(ensure_remote quotio "https://github.com/nguyenphutrong/quotio.git") ;;
|
||||
esac
|
||||
|
||||
echo -e "${BLUE}==> Fetching latest from $UPSTREAM...${NC}"
|
||||
git fetch "$REMOTE" --prune
|
||||
REMOTE_BRANCH=$(remote_default_branch "$REMOTE")
|
||||
REMOTE_REF="${REMOTE}/${REMOTE_BRANCH}"
|
||||
|
||||
echo -e "${BLUE}==> Creating review branch for $UPSTREAM (${REMOTE_REF})...${NC}"
|
||||
git switch main
|
||||
git switch -c "$BRANCH_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> Commits to review:${NC}"
|
||||
git log --oneline --graph "main..${REMOTE_REF}" | head -30 || true
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}==> File changes summary:${NC}"
|
||||
git diff --stat "main..${REMOTE_REF}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}==> Review branch created: $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. Review commits in detail:"
|
||||
echo " ${GREEN}git log -p main..$REMOTE_REF${NC}"
|
||||
echo ""
|
||||
echo "2. View specific files:"
|
||||
echo " ${GREEN}git show $REMOTE_REF:path/to/file${NC}"
|
||||
echo ""
|
||||
echo "3. Cherry-pick specific commits:"
|
||||
echo " ${GREEN}git cherry-pick <commit-hash>${NC}"
|
||||
echo ""
|
||||
echo "4. Or merge all changes:"
|
||||
echo " ${GREEN}git merge $REMOTE_REF${NC}"
|
||||
echo ""
|
||||
echo "5. Test thoroughly:"
|
||||
echo " ${GREEN}./Scripts/compile_and_run.sh${NC}"
|
||||
echo ""
|
||||
echo "6. If satisfied, merge to main:"
|
||||
echo " ${GREEN}git checkout main && git merge $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
echo "7. Or discard review branch:"
|
||||
echo " ${GREEN}git checkout main && git branch -D $BRANCH_NAME${NC}"
|
||||
echo ""
|
||||
|
||||
# Create a review log file
|
||||
LOG_FILE="upstream-review-${UPSTREAM}-${DATE}.txt"
|
||||
echo "=== Upstream Review: $UPSTREAM @ $DATE ===" > "$LOG_FILE"
|
||||
echo "" >> "$LOG_FILE"
|
||||
echo "Commits:" >> "$LOG_FILE"
|
||||
git log --oneline "main..${REMOTE_REF}" >> "$LOG_FILE"
|
||||
echo "" >> "$LOG_FILE"
|
||||
echo "File changes:" >> "$LOG_FILE"
|
||||
git diff --stat "main..${REMOTE_REF}" >> "$LOG_FILE"
|
||||
|
||||
echo -e "${GREEN}Review log saved to: $LOG_FILE${NC}"
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup stable development code signing to reduce keychain prompts
|
||||
set -euo pipefail
|
||||
|
||||
echo "🔐 Setting up stable development code signing..."
|
||||
echo ""
|
||||
echo "This will create a self-signed certificate that stays consistent across rebuilds,"
|
||||
echo "reducing keychain permission prompts."
|
||||
echo ""
|
||||
|
||||
# Check if we already have a CodexBar development certificate
|
||||
CERT_NAME="CodexBar Development"
|
||||
if security find-certificate -c "$CERT_NAME" >/dev/null 2>&1; then
|
||||
echo "✅ Certificate '$CERT_NAME' already exists!"
|
||||
echo ""
|
||||
echo "To use it, add this to your shell profile (~/.zshrc or ~/.bashrc):"
|
||||
echo ""
|
||||
echo " export APP_IDENTITY='$CERT_NAME'"
|
||||
echo ""
|
||||
echo "Then restart your terminal and rebuild with ./Scripts/compile_and_run.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Creating self-signed certificate '$CERT_NAME'..."
|
||||
echo ""
|
||||
|
||||
# Create a temporary config file for the certificate
|
||||
TEMP_CONFIG=$(mktemp)
|
||||
trap "rm -f $TEMP_CONFIG" EXIT
|
||||
|
||||
cat > "$TEMP_CONFIG" <<EOF
|
||||
[ req ]
|
||||
distinguished_name = req_distinguished_name
|
||||
x509_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[ req_distinguished_name ]
|
||||
CN = $CERT_NAME
|
||||
O = CodexBar Development
|
||||
C = US
|
||||
|
||||
[ v3_req ]
|
||||
keyUsage = critical,digitalSignature
|
||||
extendedKeyUsage = codeSigning
|
||||
EOF
|
||||
|
||||
# Generate the certificate
|
||||
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
|
||||
-nodes -keyout /tmp/codexbar-dev.key -out /tmp/codexbar-dev.crt \
|
||||
-config "$TEMP_CONFIG" 2>/dev/null
|
||||
|
||||
# Convert to PKCS12 format
|
||||
openssl pkcs12 -export -out /tmp/codexbar-dev.p12 \
|
||||
-inkey /tmp/codexbar-dev.key -in /tmp/codexbar-dev.crt \
|
||||
-passout pass: 2>/dev/null
|
||||
|
||||
# Import into keychain
|
||||
security import /tmp/codexbar-dev.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security
|
||||
|
||||
# Clean up temporary files
|
||||
rm -f /tmp/codexbar-dev.{key,crt,p12}
|
||||
|
||||
echo ""
|
||||
echo "✅ Certificate created successfully!"
|
||||
echo ""
|
||||
echo "⚠️ IMPORTANT: You need to trust this certificate for code signing:"
|
||||
echo ""
|
||||
echo "1. Open Keychain Access.app"
|
||||
echo "2. Find '$CERT_NAME' in the 'login' keychain"
|
||||
echo "3. Double-click it"
|
||||
echo "4. Expand 'Trust' section"
|
||||
echo "5. Set 'Code Signing' to 'Always Trust'"
|
||||
echo "6. Close the window (enter your password when prompted)"
|
||||
echo ""
|
||||
echo "Then add this to your shell profile (~/.zshrc or ~/.bashrc):"
|
||||
echo ""
|
||||
echo " export APP_IDENTITY='$CERT_NAME'"
|
||||
echo ""
|
||||
echo "Restart your terminal and rebuild with ./Scripts/compile_and_run.sh"
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="CodexBar"
|
||||
APP_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)"
|
||||
APP_BUNDLE="CodexBar.app"
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/version.env"
|
||||
source "$ROOT/Scripts/release_artifacts.sh"
|
||||
source "$ROOT/Scripts/package_product_paths.sh"
|
||||
source "$ROOT/Scripts/release_dsym_paths.sh"
|
||||
|
||||
verify_distribution_policy() {
|
||||
local app=$1
|
||||
if command -v syspolicy_check >/dev/null 2>&1; then
|
||||
syspolicy_check distribution "$app"
|
||||
else
|
||||
spctl -a -t exec -vv "$app"
|
||||
fi
|
||||
}
|
||||
|
||||
# Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64).
|
||||
ARCHES_VALUE=${ARCHES:-"arm64 x86_64"}
|
||||
ZIP_NAME=$(codexbar_app_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE")
|
||||
DSYM_ZIP=$(codexbar_dsym_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE")
|
||||
|
||||
if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then
|
||||
echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NOTARIZATION_TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-notarize.XXXXXX")
|
||||
chmod 700 "$NOTARIZATION_TEMP_DIR"
|
||||
API_KEY_PATH="$NOTARIZATION_TEMP_DIR/codexbar-api-key.p8"
|
||||
NOTARIZATION_ZIP="$NOTARIZATION_TEMP_DIR/${APP_NAME}Notarize.zip"
|
||||
trap 'rm -rf "$NOTARIZATION_TEMP_DIR"' EXIT
|
||||
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_PATH"
|
||||
)
|
||||
chmod 600 "$API_KEY_PATH"
|
||||
|
||||
ARCH_LIST=( ${ARCHES_VALUE} )
|
||||
ARCHES="${ARCHES_VALUE}" CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release
|
||||
|
||||
ENTITLEMENTS_DIR="$ROOT/.build/entitlements"
|
||||
APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements"
|
||||
WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements"
|
||||
|
||||
echo "Signing with $APP_IDENTITY"
|
||||
if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarCLI" ]]; then
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
"$APP_BUNDLE/Contents/Helpers/CodexBarCLI"
|
||||
fi
|
||||
if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
"$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog"
|
||||
fi
|
||||
if [[ -d "$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex" ]]; then
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
--entitlements "$WIDGET_ENTITLEMENTS" \
|
||||
"$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex"
|
||||
fi
|
||||
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
|
||||
--entitlements "$APP_ENTITLEMENTS" \
|
||||
"$APP_BUNDLE"
|
||||
|
||||
DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto}
|
||||
"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$NOTARIZATION_ZIP"
|
||||
|
||||
echo "Submitting for notarization"
|
||||
xcrun notarytool submit "$NOTARIZATION_ZIP" \
|
||||
--key "$API_KEY_PATH" \
|
||||
--key-id "$APP_STORE_CONNECT_KEY_ID" \
|
||||
--issuer "$APP_STORE_CONNECT_ISSUER_ID" \
|
||||
--wait
|
||||
|
||||
echo "Stapling ticket"
|
||||
xcrun stapler staple "$APP_BUNDLE"
|
||||
|
||||
# Strip any extended attributes that would create AppleDouble files when zipping
|
||||
xattr -cr "$APP_BUNDLE"
|
||||
find "$APP_BUNDLE" -name '._*' -delete
|
||||
|
||||
"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$ZIP_NAME"
|
||||
|
||||
verify_distribution_policy "$APP_BUNDLE"
|
||||
stapler validate "$APP_BUNDLE"
|
||||
|
||||
echo "Packaging dSYM"
|
||||
DSYM_STAGE_ROOT="$ROOT/.build/package-products/release"
|
||||
DSYM_PATHS=()
|
||||
for ARCH in "${ARCH_LIST[@]}"; do
|
||||
STAGED_DSYM="$DSYM_STAGE_ROOT/$ARCH/${APP_NAME}.dSYM"
|
||||
if [[ -d "$STAGED_DSYM" ]]; then
|
||||
DSYM_PATHS+=("$STAGED_DSYM")
|
||||
continue
|
||||
fi
|
||||
BIN_DIR=$(codexbar_swiftpm_bin_path release "$ARCH")
|
||||
DSYM_PATHS+=("$(codexbar_resolve_dsym_path "$DSYM_STAGE_ROOT" "$BIN_DIR" "$APP_NAME" "$ARCH")")
|
||||
done
|
||||
|
||||
DSYM_PATH="${DSYM_PATHS[0]}"
|
||||
DSYM_DWARF_PATHS=()
|
||||
for ((index = 0; index < ${#ARCH_LIST[@]}; index++)); do
|
||||
ARCH="${ARCH_LIST[$index]}"
|
||||
if ! ARCH_DSYM=$(codexbar_require_dsym_dwarf_for_arch "${DSYM_PATHS[$index]}" "$APP_NAME" "$ARCH"); then
|
||||
exit 1
|
||||
fi
|
||||
DSYM_DWARF_PATHS+=("$ARCH_DSYM")
|
||||
done
|
||||
|
||||
if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then
|
||||
MERGED_DSYM_ROOT="${DSYM_STAGE_ROOT}/${APP_NAME}.dSYM-universal"
|
||||
MERGED_DSYM="${MERGED_DSYM_ROOT}/${APP_NAME}.dSYM"
|
||||
rm -rf "$MERGED_DSYM_ROOT"
|
||||
mkdir -p "$MERGED_DSYM_ROOT"
|
||||
cp -R "$DSYM_PATH" "$MERGED_DSYM"
|
||||
DWARF_PATH="${MERGED_DSYM}/Contents/Resources/DWARF/${APP_NAME}"
|
||||
lipo -create "${DSYM_DWARF_PATHS[@]}" -output "$DWARF_PATH"
|
||||
DSYM_PATH="$MERGED_DSYM"
|
||||
fi
|
||||
if [[ ! -d "$DSYM_PATH" ]]; then
|
||||
echo "Missing dSYM at SwiftPM-reported path: $DSYM_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
codexbar_verify_dsym_matches_binary \
|
||||
"$APP_BUNDLE/Contents/MacOS/$APP_NAME" \
|
||||
"$DSYM_PATH/Contents/Resources/DWARF/$APP_NAME" \
|
||||
"${ARCH_LIST[@]}"
|
||||
"$DITTO_BIN" --norsrc -c -k --keepParent "$DSYM_PATH" "$DSYM_ZIP"
|
||||
|
||||
echo "Done: $ZIP_NAME"
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
codexbar_resolve_sparkle_version_child() {
|
||||
local versions_dir="$1"
|
||||
local candidate="$2"
|
||||
local label="$3"
|
||||
local versions_root resolved
|
||||
|
||||
versions_root=$(cd "$versions_dir" && pwd -P)
|
||||
if ! resolved=$(cd "$candidate" 2>/dev/null && pwd -P); then
|
||||
echo "ERROR: Sparkle ${label} does not resolve: ${candidate}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$(dirname "$resolved")" != "$versions_root" ]]; then
|
||||
echo "ERROR: Sparkle ${label} resolves outside the framework versions directory: ${candidate}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$resolved"
|
||||
}
|
||||
|
||||
codexbar_sparkle_version_dir() {
|
||||
local sparkle="$1"
|
||||
local versions_dir="${sparkle}/Versions"
|
||||
|
||||
if [[ -L "$sparkle" ]]; then
|
||||
echo "ERROR: Sparkle framework root must not be a symlink: ${sparkle}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -L "$versions_dir" ]]; then
|
||||
echo "ERROR: Sparkle versions directory must not be a symlink: ${versions_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$versions_dir" ]]; then
|
||||
echo "ERROR: Missing Sparkle versions directory: ${versions_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -e "$versions_dir/Current" || -L "$versions_dir/Current" ]]; then
|
||||
local current
|
||||
if ! current=$(codexbar_resolve_sparkle_version_child "$versions_dir" "$versions_dir/Current" "Versions/Current"); then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$current"
|
||||
return
|
||||
fi
|
||||
|
||||
local version_dirs=()
|
||||
local candidate
|
||||
shopt -s nullglob
|
||||
for candidate in "$versions_dir"/*; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
version_dirs+=("$candidate")
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
case "${#version_dirs[@]}" in
|
||||
0)
|
||||
echo "ERROR: Sparkle framework has no version directory under: ${versions_dir}" >&2
|
||||
return 1
|
||||
;;
|
||||
1)
|
||||
local resolved
|
||||
if ! resolved=$(codexbar_resolve_sparkle_version_child \
|
||||
"$versions_dir" "${version_dirs[0]}" "version directory"); then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$resolved"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Sparkle framework has multiple version directories and no Versions/Current symlink: ${versions_dir}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
codexbar_require_sparkle_signing_target() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
local trusted_root="$3"
|
||||
local resolved trusted_root_resolved
|
||||
|
||||
if [[ -L "$path" ]]; then
|
||||
echo "ERROR: Sparkle signing target must not be a symlink (${label}): ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$path" ]]; then
|
||||
echo "ERROR: Missing Sparkle signing target (${label}): ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! trusted_root_resolved=$(cd "$trusted_root" 2>/dev/null && pwd -P); then
|
||||
echo "ERROR: Sparkle signing root does not resolve (${label}): ${trusted_root}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -d "$path" ]]; then
|
||||
resolved=$(cd "$path" && pwd -P)
|
||||
else
|
||||
resolved="$(cd "$(dirname "$path")" && pwd -P)/$(basename "$path")"
|
||||
fi
|
||||
if [[ "$resolved" != "$trusted_root_resolved" &&
|
||||
"${resolved#"$trusted_root_resolved"/}" == "$resolved" ]]; then
|
||||
echo "ERROR: Sparkle signing target resolves outside its trusted root (${label}): ${path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$resolved"
|
||||
}
|
||||
|
||||
codexbar_sparkle_signing_targets() {
|
||||
local sparkle="$1"
|
||||
local version_dir
|
||||
if ! version_dir=$(codexbar_sparkle_version_dir "$sparkle"); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir/Sparkle" "framework binary" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir/Autoupdate" "autoupdate tool" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir/Updater.app" "updater app" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/Updater.app/Contents/MacOS/Updater" "updater executable" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Downloader.xpc" "downloader xpc" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \
|
||||
"downloader executable" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Installer.xpc" "installer xpc" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target \
|
||||
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer" \
|
||||
"installer executable" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$version_dir" "framework version" "$version_dir" || return 1
|
||||
codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
content: ["./docs/index.html", "./docs/site.js"],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
tablet: "769px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||
mono: ["SFMono-Regular", "SF Mono", "Menlo", "monospace"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GROUP_SIZE="${CODEXBAR_TEST_GROUP_SIZE:-12}"
|
||||
SUITE_TIMEOUT="${CODEXBAR_TEST_SUITE_TIMEOUT:-180}"
|
||||
RETRY_NON_TIMEOUT_FAILURES="${CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES:-1}"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
# Defense in depth: test processes also self-detect, but keep this explicit so runner changes cannot
|
||||
# expose the user's login Keychain. Deliberate isolated Keychain tests must opt in by setting the allow flag.
|
||||
if [[ "${CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS:-}" != "1" ]]; then
|
||||
export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1
|
||||
fi
|
||||
|
||||
ARGS=(
|
||||
--group-size "${GROUP_SIZE}"
|
||||
--timeout "${SUITE_TIMEOUT}"
|
||||
)
|
||||
|
||||
case "${RETRY_NON_TIMEOUT_FAILURES}" in
|
||||
0) ARGS+=(--no-retry-non-timeout-failures) ;;
|
||||
1) ;;
|
||||
*)
|
||||
echo "CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES must be 0 or 1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "${CODEXBAR_TEST_SHARD_INDEX:-}" || -n "${CODEXBAR_TEST_SHARD_COUNT:-}" ]]; then
|
||||
ARGS+=(
|
||||
--shard-index "${CODEXBAR_TEST_SHARD_INDEX:?CODEXBAR_TEST_SHARD_COUNT requires CODEXBAR_TEST_SHARD_INDEX}"
|
||||
--shard-count "${CODEXBAR_TEST_SHARD_COUNT:?CODEXBAR_TEST_SHARD_INDEX requires CODEXBAR_TEST_SHARD_COUNT}"
|
||||
)
|
||||
fi
|
||||
|
||||
exec python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" "${ARGS[@]}" "$@"
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
assert_gate() {
|
||||
local expected="$1"
|
||||
local name="$2"
|
||||
local paths_file="${tmp_dir}/${name}.paths"
|
||||
local output_file="${tmp_dir}/${name}.output"
|
||||
shift 2
|
||||
|
||||
printf '%s\n' "$@" > "$paths_file"
|
||||
GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null
|
||||
local actual
|
||||
actual="$(sed -n 's/^macos-tests=//p' "$output_file")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
printf '%s: expected macos-tests=%s, got %s\n' "$name" "$expected" "${actual:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local reason
|
||||
reason="$(sed -n 's/^macos-tests-reason=//p' "$output_file")"
|
||||
if [[ -z "$reason" ]]; then
|
||||
printf '%s: expected macos-tests-reason output\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local path_count
|
||||
path_count="$(sed -n 's/^changed-path-count=//p' "$output_file")"
|
||||
if ! [[ "$path_count" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s: expected numeric changed-path-count output, got %s\n' \
|
||||
"$name" "${path_count:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$expected" == false && "$reason" != "docs/site-only changes covered by portable checks" ]]; then
|
||||
printf '%s: expected docs/site skip reason, got %s\n' "$name" "$reason" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_gate false docs-only $'M\tdocs/providers.md' $'M\tREADME.md'
|
||||
assert_gate true configuration-doc $'M\tdocs/configuration.md'
|
||||
assert_gate true rename-to-configuration-doc $'R100\tdocs/old.md\tdocs/configuration.md'
|
||||
assert_gate true rename-from-configuration-doc $'R100\tdocs/configuration.md\tdocs/new.md'
|
||||
assert_gate true agents-contract $'M\tAGENTS.md'
|
||||
assert_gate true rename-to-agents-contract $'R100\tdocs/old.md\tAGENTS.md'
|
||||
assert_gate true rename-from-agents-contract $'R100\tAGENTS.md\tdocs/new.md'
|
||||
assert_gate true source $'M\tSources/CodexBar/App.swift'
|
||||
assert_gate false docs-site $'M\tdocs/index.html' $'M\tdocs/site.css' $'M\tdocs/site.js' \
|
||||
$'M\tdocs/site-locales.mjs' $'M\tdocs/social.html' $'M\tdocs/social.png' \
|
||||
$'M\tdocs/CNAME' $'M\tdocs/.nojekyll' $'M\tdocs/llms.txt'
|
||||
assert_gate false docs-site-assets $'M\tdocs/icon.png' $'M\tdocs/logos/provider-logo.svg'
|
||||
assert_gate true docs-unknown-code $'M\tdocs/custom-tool.js'
|
||||
assert_gate true docs-site-with-config $'M\tdocs/site.css' $'M\tdocs/configuration.md'
|
||||
assert_gate true empty
|
||||
assert_gate true source-to-docs $'R100\tSources/CodexBar/App.swift\tdocs/App.md'
|
||||
assert_gate true docs-to-source $'R100\tdocs/App.md\tSources/CodexBar/App.swift'
|
||||
assert_gate false docs-to-site $'R100\tdocs/old.md\tdocs/site.css'
|
||||
|
||||
assert_gate_fails() {
|
||||
local name="$1"
|
||||
local paths_file="${tmp_dir}/${name}.paths"
|
||||
local output_file="${tmp_dir}/${name}.output"
|
||||
shift
|
||||
|
||||
printf '%s\n' "$@" > "$paths_file"
|
||||
if GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null 2>&1; then
|
||||
printf '%s: malformed gate input unexpectedly succeeded\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -s "$output_file" ]]; then
|
||||
printf '%s: malformed gate input emitted an output\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_gate_fails missing-rename-target $'R100\tREADME.md'
|
||||
assert_gate_fails extra-modified-path $'M\tREADME.md\tdocs/configuration.md'
|
||||
assert_gate_fails missing-rename-score $'R\tREADME.md\tdocs/README.md'
|
||||
assert_gate_fails invalid-rename-score $'Rfoo\tREADME.md\tdocs/README.md'
|
||||
assert_gate_fails out-of-range-rename-score $'R101\tREADME.md\tdocs/README.md'
|
||||
|
||||
unterminated_paths="${tmp_dir}/unterminated.paths"
|
||||
unterminated_output="${tmp_dir}/unterminated.output"
|
||||
printf '%s' $'M\tREADME.md\tdocs/configuration.md' > "$unterminated_paths"
|
||||
if GITHUB_OUTPUT="$unterminated_output" \
|
||||
"${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$unterminated_paths" >/dev/null 2>&1
|
||||
then
|
||||
printf 'unterminated malformed gate input unexpectedly succeeded\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -s "$unterminated_output" ]]; then
|
||||
printf 'unterminated malformed gate input emitted an output\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify="${ROOT_DIR}/Scripts/ci_verify_test_jobs.sh"
|
||||
"$verify" success success true success >/dev/null
|
||||
"$verify" success success false skipped >/dev/null
|
||||
|
||||
assert_verify_fails() {
|
||||
if "$verify" "$@" >/dev/null 2>&1; then
|
||||
printf 'unexpected aggregate success: %s\n' "$*" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_verify_fails success success true skipped
|
||||
assert_verify_fails success success false success
|
||||
assert_verify_fails success success "" skipped
|
||||
assert_verify_fails failure success true success
|
||||
assert_verify_fails success failure true success
|
||||
|
||||
printf 'CI macOS path gate tests passed.\n'
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PREV_TAG=${1:?"pass previous release tag (e.g. v0.1.0)"}
|
||||
CUR_TAG=${2:?"pass current release tag (e.g. v0.1.1)"}
|
||||
|
||||
PREV_VER=${PREV_TAG#v}
|
||||
CUR_VER=${CUR_TAG#v}
|
||||
APP_NAME="CodexBar"
|
||||
|
||||
ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-macos-universal-${PREV_VER}.zip"
|
||||
TMP_DIR=$(mktemp -d /tmp/codexbar-live.XXXX)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
echo "Downloading previous release $PREV_TAG from $ZIP_URL"
|
||||
curl --fail --location --output "$TMP_DIR/prev.zip" "$ZIP_URL"
|
||||
|
||||
echo "Installing previous release to /Applications/${APP_NAME}.app"
|
||||
osascript -e 'tell application "CodexBar" to quit' >/dev/null 2>&1 || true
|
||||
for _ in {1..20}; do
|
||||
pgrep -x "$APP_NAME" >/dev/null || break
|
||||
sleep 0.25
|
||||
done
|
||||
if pgrep -x "$APP_NAME" >/dev/null; then
|
||||
echo "ERROR: ${APP_NAME} did not quit before replacement." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /Applications/${APP_NAME}.app
|
||||
ditto -x -k "$TMP_DIR/prev.zip" "$TMP_DIR"
|
||||
ditto "$TMP_DIR/${APP_NAME}.app" /Applications/${APP_NAME}.app
|
||||
|
||||
echo "Launching previous build…"
|
||||
open -n /Applications/${APP_NAME}.app
|
||||
sleep 4
|
||||
|
||||
cat <<'MSG'
|
||||
Manual step: trigger "Check for Updates…" in the app and install the update.
|
||||
Expect to land on the newly released version. When done, confirm below.
|
||||
MSG
|
||||
|
||||
read -rp "Did the update succeed from ${PREV_TAG} to ${CUR_TAG}? (y/N) " answer
|
||||
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
|
||||
echo "Live update test NOT confirmed; failing per RUN_SPARKLE_UPDATE_TEST." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
installed_ver=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
|
||||
"/Applications/${APP_NAME}.app/Contents/Info.plist")
|
||||
if [[ "$installed_ver" != "$CUR_VER" ]]; then
|
||||
echo "Live update reported success but installed ${installed_ver}; expected ${CUR_VER}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Live update test confirmed."
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/Scripts/package_product_paths.sh"
|
||||
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-paths.XXXXXX")
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
NATIVE_DIR="$TEMP_DIR/.build/arm64-apple-macosx/release"
|
||||
SWIFTBUILD_DIR="$TEMP_DIR/.build/out/Products/Release"
|
||||
STAGE_ROOT="$TEMP_DIR/.build/package-products/release"
|
||||
mkdir -p "$NATIVE_DIR/CodexBar.dSYM" "$SWIFTBUILD_DIR/Sparkle.framework" "$SWIFTBUILD_DIR/CodexBar.dSYM"
|
||||
touch "$NATIVE_DIR/CodexBar" "$SWIFTBUILD_DIR/CodexBar"
|
||||
|
||||
native=$(codexbar_require_product_file "$NATIVE_DIR" CodexBar arm64)
|
||||
[[ "$native" == "$NATIVE_DIR/CodexBar" ]]
|
||||
|
||||
swiftbuild=$(codexbar_require_product_file "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$swiftbuild" == "$SWIFTBUILD_DIR/CodexBar" ]]
|
||||
|
||||
framework=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging)
|
||||
[[ "$framework" == "$SWIFTBUILD_DIR/Sparkle.framework" ]]
|
||||
|
||||
dsym=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" CodexBar.dSYM release)
|
||||
[[ "$dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]]
|
||||
|
||||
resolved=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$resolved" == "$SWIFTBUILD_DIR/CodexBar" ]]
|
||||
|
||||
resolved_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$resolved_dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]]
|
||||
|
||||
mkdir -p "$STAGE_ROOT/arm64/CodexBar.dSYM"
|
||||
touch "$STAGE_ROOT/arm64/CodexBar"
|
||||
staged=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$staged" == "$STAGE_ROOT/arm64/CodexBar" ]]
|
||||
staged_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
|
||||
[[ "$staged_dsym" == "$STAGE_ROOT/arm64/CodexBar.dSYM" ]]
|
||||
|
||||
rm -rf "$STAGE_ROOT"
|
||||
rm "$SWIFTBUILD_DIR/CodexBar"
|
||||
if codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/missing-file.log"; then
|
||||
echo "ERROR: Missing reported product unexpectedly fell back to legacy output." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$SWIFTBUILD_DIR/CodexBar" "$TEMP_DIR/missing-file.log"
|
||||
|
||||
rm -rf "$SWIFTBUILD_DIR/Sparkle.framework"
|
||||
if codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging \
|
||||
2>"$TEMP_DIR/missing-directory.log"; then
|
||||
echo "ERROR: Missing reported framework was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$SWIFTBUILD_DIR/Sparkle.framework" "$TEMP_DIR/missing-directory.log"
|
||||
|
||||
rm -rf "$SWIFTBUILD_DIR/CodexBar.dSYM"
|
||||
if codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/missing-dsym.log"; then
|
||||
echo "ERROR: Missing reported dSYM unexpectedly fell back to legacy output." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$SWIFTBUILD_DIR/CodexBar.dSYM" "$TEMP_DIR/missing-dsym.log"
|
||||
|
||||
swift() {
|
||||
[[ "$*" == "build --show-bin-path -c release --arch arm64" ]]
|
||||
printf '%s\n' "$SWIFTBUILD_DIR"
|
||||
}
|
||||
reported=$(codexbar_swiftpm_bin_path release arm64)
|
||||
[[ "$reported" == "$SWIFTBUILD_DIR" ]]
|
||||
|
||||
swift() {
|
||||
return 23
|
||||
}
|
||||
if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/query.log"; then
|
||||
echo "ERROR: SwiftPM bin-path query failure was ignored." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "SwiftPM failed to report" "$TEMP_DIR/query.log"
|
||||
|
||||
swift() {
|
||||
return 0
|
||||
}
|
||||
if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/empty.log"; then
|
||||
echo "ERROR: Empty SwiftPM bin path was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "SwiftPM reported an empty" "$TEMP_DIR/empty.log"
|
||||
|
||||
echo "Package product path tests passed."
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh"
|
||||
RELEASE_SCRIPT="$ROOT/Scripts/sign-and-notarize.sh"
|
||||
FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-signing-functions.XXXXXX")
|
||||
trap 'rm -f "$FUNCTIONS_FILE"' EXIT
|
||||
|
||||
python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script = Path(sys.argv[1]).read_text()
|
||||
start = script.index('resolve_package_signing_mode() {')
|
||||
end = script.index('\n}\n', start) + 3
|
||||
Path(sys.argv[2]).write_text(script[start:end])
|
||||
PY
|
||||
|
||||
source "$FUNCTIONS_FILE"
|
||||
|
||||
unset CODEXBAR_SIGNING
|
||||
SIGNING_MODE=
|
||||
resolve_package_signing_mode
|
||||
[[ "$SIGNING_MODE" == "adhoc" ]]
|
||||
|
||||
CODEXBAR_SIGNING=identity
|
||||
resolve_package_signing_mode
|
||||
[[ "$SIGNING_MODE" == "identity" ]]
|
||||
|
||||
CODEXBAR_SIGNING=invalid
|
||||
if resolve_package_signing_mode 2>/dev/null; then
|
||||
echo "Invalid package signing mode unexpectedly succeeded" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -Fq 'CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release' "$RELEASE_SCRIPT"
|
||||
|
||||
echo "Package signing tests passed."
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh"
|
||||
FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-strip-functions.XXXXXX")
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-strip.XXXXXX")
|
||||
trap 'rm -rf "$FUNCTIONS_FILE" "$TEMP_DIR"' EXIT
|
||||
|
||||
python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script = Path(sys.argv[1]).read_text()
|
||||
start = script.index('strip_release_binary() {')
|
||||
end = script.index('\n}\n', start) + 3
|
||||
Path(sys.argv[2]).write_text(script[start:end])
|
||||
PY
|
||||
|
||||
xcrun() {
|
||||
[[ "$1" == "strip" && "$2" == "-x" ]]
|
||||
printf '%s\n' "$3" >> "$STRIP_LOG"
|
||||
}
|
||||
|
||||
source "$FUNCTIONS_FILE"
|
||||
|
||||
binary="$TEMP_DIR/CodexBar"
|
||||
touch "$binary"
|
||||
|
||||
STRIP_LOG="$TEMP_DIR/release.log"
|
||||
LOWER_CONF=release
|
||||
strip_release_binary "$binary"
|
||||
grep -Fqx "$binary" "$STRIP_LOG"
|
||||
|
||||
STRIP_LOG="$TEMP_DIR/debug.log"
|
||||
LOWER_CONF=debug
|
||||
strip_release_binary "$binary"
|
||||
[[ ! -e "$STRIP_LOG" ]]
|
||||
|
||||
STRIP_LOG="$TEMP_DIR/missing.log"
|
||||
LOWER_CONF=release
|
||||
strip_release_binary "$TEMP_DIR/MissingBinary"
|
||||
[[ ! -e "$STRIP_LOG" ]]
|
||||
|
||||
echo "Package strip tests passed."
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/Scripts/release_dsym_paths.sh"
|
||||
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-release-dsym-paths.XXXXXX")
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
make_dsym() {
|
||||
local dsym_path="$1"
|
||||
mkdir -p "$dsym_path/Contents/Resources/DWARF"
|
||||
touch "$dsym_path/Contents/Resources/DWARF/CodexBar"
|
||||
}
|
||||
|
||||
ARM_DSYM="$TEMP_DIR/CodexBar arm64.dSYM"
|
||||
UNIVERSAL_DSYM="$TEMP_DIR/CodexBar universal.dSYM"
|
||||
WRONG_ARCH_DSYM="$TEMP_DIR/CodexBar stale.dSYM"
|
||||
MISSING_DWARF_DSYM="$TEMP_DIR/CodexBar missing.dSYM"
|
||||
APP_BINARY="$TEMP_DIR/CodexBar.app"
|
||||
MATCHING_DWARF="$TEMP_DIR/CodexBar matching"
|
||||
MISMATCHED_DWARF="$TEMP_DIR/CodexBar mismatched"
|
||||
MISSING_UUID_DWARF="$TEMP_DIR/CodexBar missing UUID"
|
||||
make_dsym "$ARM_DSYM"
|
||||
make_dsym "$UNIVERSAL_DSYM"
|
||||
make_dsym "$WRONG_ARCH_DSYM"
|
||||
mkdir -p "$MISSING_DWARF_DSYM/Contents/Resources/DWARF"
|
||||
touch "$APP_BINARY" "$MATCHING_DWARF" "$MISMATCHED_DWARF" "$MISSING_UUID_DWARF"
|
||||
|
||||
lipo() {
|
||||
[[ "$1" == "-archs" ]]
|
||||
case "$2" in
|
||||
"$ARM_DSYM/Contents/Resources/DWARF/CodexBar")
|
||||
printf '%s\n' "arm64"
|
||||
;;
|
||||
"$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar")
|
||||
printf '%s\n' "arm64 x86_64"
|
||||
;;
|
||||
"$WRONG_ARCH_DSYM/Contents/Resources/DWARF/CodexBar")
|
||||
printf '%s\n' "x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected lipo path: $2" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
dwarfdump() {
|
||||
[[ "$1" == "--uuid" ]]
|
||||
case "$2" in
|
||||
"$APP_BINARY" | "$MATCHING_DWARF")
|
||||
printf '%s\n' \
|
||||
"UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \
|
||||
"UUID: BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB (x86_64) $2"
|
||||
;;
|
||||
"$MISMATCHED_DWARF")
|
||||
printf '%s\n' \
|
||||
"UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \
|
||||
"UUID: CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC (x86_64) $2"
|
||||
;;
|
||||
"$MISSING_UUID_DWARF")
|
||||
printf '%s\n' "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected dwarfdump path: $2" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
arm_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$ARM_DSYM" CodexBar arm64)
|
||||
[[ "$arm_dwarf" == "$ARM_DSYM/Contents/Resources/DWARF/CodexBar" ]]
|
||||
|
||||
x86_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$UNIVERSAL_DSYM" CodexBar x86_64)
|
||||
[[ "$x86_dwarf" == "$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar" ]]
|
||||
|
||||
if codexbar_require_dsym_dwarf_for_arch "$MISSING_DWARF_DSYM" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/missing-dwarf.log"; then
|
||||
echo "ERROR: Missing dSYM DWARF file was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "$MISSING_DWARF_DSYM/Contents/Resources/DWARF/CodexBar" "$TEMP_DIR/missing-dwarf.log"
|
||||
|
||||
if codexbar_require_dsym_dwarf_for_arch "$WRONG_ARCH_DSYM" CodexBar arm64 \
|
||||
2>"$TEMP_DIR/wrong-arch.log"; then
|
||||
echo "ERROR: Wrong-architecture dSYM was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "required architecture: arm64" "$TEMP_DIR/wrong-arch.log"
|
||||
|
||||
codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MATCHING_DWARF" arm64 x86_64
|
||||
|
||||
if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISMATCHED_DWARF" arm64 x86_64 \
|
||||
2>"$TEMP_DIR/mismatched-uuid.log"; then
|
||||
echo "ERROR: Mismatched dSYM UUID was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "dSYM UUID mismatch for x86_64" "$TEMP_DIR/mismatched-uuid.log"
|
||||
|
||||
if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISSING_UUID_DWARF" arm64 x86_64 \
|
||||
2>"$TEMP_DIR/missing-uuid.log"; then
|
||||
echo "ERROR: Missing dSYM UUID was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "Missing UUID for x86_64" "$TEMP_DIR/missing-uuid.log"
|
||||
|
||||
echo "Release dSYM path tests passed."
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-repository-size.XXXXXX")
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
mkdir -p "$TEMP_DIR/Scripts"
|
||||
cp "$ROOT_DIR/Scripts/check_repository_size.sh" "$TEMP_DIR/Scripts/"
|
||||
git -C "$TEMP_DIR" init --quiet
|
||||
empty_output=$("$TEMP_DIR/Scripts/check_repository_size.sh")
|
||||
grep -Fq 'repository size OK: 0 tracked files' <<<"$empty_output"
|
||||
|
||||
printf 'small source file\n' > "$TEMP_DIR/source.txt"
|
||||
git -C "$TEMP_DIR" add source.txt Scripts/check_repository_size.sh
|
||||
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
dd if=/dev/zero of="$TEMP_DIR/untracked.bin" bs=1024 count=2049 2>/dev/null
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
dd if=/dev/zero of="$TEMP_DIR/boundary.bin" bs=1024 count=2048 2>/dev/null
|
||||
git -C "$TEMP_DIR" add boundary.bin
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
printf 'x' >> "$TEMP_DIR/boundary.bin"
|
||||
git -C "$TEMP_DIR" add boundary.bin
|
||||
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/large.log" 2>&1; then
|
||||
printf 'ERROR: staged blob one byte above the limit was accepted.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'tracked file exceeds 2097152 bytes: boundary.bin (2097153 bytes)' "$TEMP_DIR/large.log"
|
||||
|
||||
git -C "$TEMP_DIR" rm --cached --force --quiet boundary.bin
|
||||
git -C "$TEMP_DIR" add untracked.bin
|
||||
printf 'working tree is now small\n' > "$TEMP_DIR/untracked.bin"
|
||||
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/staged-large.log" 2>&1; then
|
||||
printf 'ERROR: oversized staged blob was accepted after its working-tree file changed.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'tracked file exceeds 2097152 bytes: untracked.bin (2098176 bytes)' "$TEMP_DIR/staged-large.log"
|
||||
|
||||
git -C "$TEMP_DIR" rm --cached --force --quiet untracked.bin
|
||||
printf 'small staged blob\n' > "$TEMP_DIR/index-is-authoritative.bin"
|
||||
git -C "$TEMP_DIR" add index-is-authoritative.bin
|
||||
dd if=/dev/zero of="$TEMP_DIR/index-is-authoritative.bin" bs=1024 count=2049 2>/dev/null
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
odd_path=$'odd\nname.txt'
|
||||
printf 'small source file\n' > "$TEMP_DIR/$odd_path"
|
||||
git -C "$TEMP_DIR" add "$odd_path"
|
||||
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
|
||||
|
||||
artifacts=(
|
||||
"CodexBar 2.app/Contents/MacOS/CodexBar"
|
||||
"CodexBar.dSYM/Contents/Info.plist"
|
||||
"CodexBar.xcarchive/Products/Applications/CodexBar.app/Contents/Info.plist"
|
||||
"CodexBar.xcresult/Data/data"
|
||||
"CodexBar.ipa"
|
||||
"CodexBar.zip"
|
||||
"CodexBar.delta"
|
||||
"CodexBar.dmg"
|
||||
"CodexBar.pkg"
|
||||
"CodexBar.tar.gz"
|
||||
"CodexBar.tgz"
|
||||
)
|
||||
for artifact in "${artifacts[@]}"; do
|
||||
mkdir -p "$TEMP_DIR/$(dirname "$artifact")"
|
||||
printf 'release artifact\n' > "$TEMP_DIR/$artifact"
|
||||
git -C "$TEMP_DIR" add -f "$artifact"
|
||||
done
|
||||
ln -s source.txt "$TEMP_DIR/CodexBar-latest.dmg"
|
||||
git -C "$TEMP_DIR" add -f CodexBar-latest.dmg
|
||||
rm "$TEMP_DIR/CodexBar.zip"
|
||||
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/artifact.log" 2>&1; then
|
||||
printf 'ERROR: tracked release artifacts were accepted.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
for artifact in "${artifacts[@]}" CodexBar-latest.dmg; do
|
||||
grep -Fq "generated artifact is tracked: $artifact" "$TEMP_DIR/artifact.log"
|
||||
done
|
||||
|
||||
printf 'Repository size tests passed.\n'
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/Scripts/sparkle_signing_paths.sh"
|
||||
|
||||
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-sparkle-signing.XXXXXX")
|
||||
TEMP_DIR=$(cd "$TEMP_DIR" && pwd -P)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
make_sparkle_version() {
|
||||
local sparkle="$1"
|
||||
local version="$2"
|
||||
local version_dir="$sparkle/Versions/$version"
|
||||
|
||||
mkdir -p \
|
||||
"$version_dir/Updater.app/Contents/MacOS" \
|
||||
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS" \
|
||||
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS"
|
||||
touch \
|
||||
"$version_dir/Sparkle" \
|
||||
"$version_dir/Autoupdate" \
|
||||
"$version_dir/Updater.app/Contents/MacOS/Updater" \
|
||||
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \
|
||||
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer"
|
||||
}
|
||||
|
||||
SINGLE="$TEMP_DIR/Single Sparkle.framework"
|
||||
make_sparkle_version "$SINGLE" B
|
||||
single_version=$(codexbar_sparkle_version_dir "$SINGLE")
|
||||
[[ "$single_version" == "$SINGLE/Versions/B" ]]
|
||||
|
||||
single_targets=$(codexbar_sparkle_signing_targets "$SINGLE")
|
||||
grep -Fqx "$SINGLE" <<<"$single_targets"
|
||||
grep -Fqx "$SINGLE/Versions/B/Sparkle" <<<"$single_targets"
|
||||
grep -Fqx "$SINGLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" <<<"$single_targets"
|
||||
|
||||
CURRENT="$TEMP_DIR/Current Sparkle.framework"
|
||||
make_sparkle_version "$CURRENT" A
|
||||
make_sparkle_version "$CURRENT" C
|
||||
ln -s C "$CURRENT/Versions/Current"
|
||||
current_version=$(codexbar_sparkle_version_dir "$CURRENT")
|
||||
[[ "$current_version" == "$CURRENT/Versions/C" ]]
|
||||
|
||||
rm "$CURRENT/Versions/C/Autoupdate"
|
||||
if codexbar_sparkle_signing_targets "$CURRENT" >"$TEMP_DIR/missing-target.out" 2>"$TEMP_DIR/missing-target.log"; then
|
||||
echo "ERROR: Missing Sparkle signing target was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "Autoupdate" "$TEMP_DIR/missing-target.log"
|
||||
|
||||
AMBIGUOUS="$TEMP_DIR/Ambiguous Sparkle.framework"
|
||||
make_sparkle_version "$AMBIGUOUS" A
|
||||
make_sparkle_version "$AMBIGUOUS" B
|
||||
if codexbar_sparkle_version_dir "$AMBIGUOUS" 2>"$TEMP_DIR/ambiguous.log"; then
|
||||
echo "ERROR: Ambiguous Sparkle versions were accepted without Versions/Current." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "multiple version directories" "$TEMP_DIR/ambiguous.log"
|
||||
|
||||
BROKEN_CURRENT="$TEMP_DIR/Broken Current Sparkle.framework"
|
||||
make_sparkle_version "$BROKEN_CURRENT" B
|
||||
ln -s Missing "$BROKEN_CURRENT/Versions/Current"
|
||||
if codexbar_sparkle_version_dir "$BROKEN_CURRENT" 2>"$TEMP_DIR/broken-current.log"; then
|
||||
echo "ERROR: Broken Sparkle Versions/Current was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "Versions/Current does not resolve" "$TEMP_DIR/broken-current.log"
|
||||
|
||||
ESCAPING_CURRENT="$TEMP_DIR/Escaping Current Sparkle.framework"
|
||||
OUTSIDE_SPARKLE="$TEMP_DIR/Outside Sparkle.framework"
|
||||
make_sparkle_version "$ESCAPING_CURRENT" B
|
||||
make_sparkle_version "$OUTSIDE_SPARKLE" C
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_CURRENT/Versions/Current"
|
||||
if codexbar_sparkle_version_dir "$ESCAPING_CURRENT" 2>"$TEMP_DIR/escaping-current.log"; then
|
||||
echo "ERROR: Escaping Sparkle Versions/Current was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-current.log"
|
||||
|
||||
SYMLINKED_VERSIONS="$TEMP_DIR/Symlinked Versions Sparkle.framework"
|
||||
mkdir -p "$SYMLINKED_VERSIONS"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions" "$SYMLINKED_VERSIONS/Versions"
|
||||
if codexbar_sparkle_version_dir "$SYMLINKED_VERSIONS" 2>"$TEMP_DIR/symlinked-versions.log"; then
|
||||
echo "ERROR: Symlinked Sparkle Versions directory was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "versions directory must not be a symlink" "$TEMP_DIR/symlinked-versions.log"
|
||||
|
||||
SYMLINKED_FRAMEWORK="$TEMP_DIR/Symlinked Sparkle.framework"
|
||||
ln -s "$OUTSIDE_SPARKLE" "$SYMLINKED_FRAMEWORK"
|
||||
if codexbar_sparkle_version_dir "$SYMLINKED_FRAMEWORK" 2>"$TEMP_DIR/symlinked-framework.log"; then
|
||||
echo "ERROR: Symlinked Sparkle framework root was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "framework root must not be a symlink" "$TEMP_DIR/symlinked-framework.log"
|
||||
|
||||
SYMLINKED_TARGET="$TEMP_DIR/Symlinked Target Sparkle.framework"
|
||||
make_sparkle_version "$SYMLINKED_TARGET" B
|
||||
rm "$SYMLINKED_TARGET/Versions/B/Autoupdate"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C/Autoupdate" "$SYMLINKED_TARGET/Versions/B/Autoupdate"
|
||||
if codexbar_sparkle_signing_targets \
|
||||
"$SYMLINKED_TARGET" >"$TEMP_DIR/symlinked-target.out" 2>"$TEMP_DIR/symlinked-target.log"; then
|
||||
echo "ERROR: Symlinked Sparkle signing target was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "signing target must not be a symlink" "$TEMP_DIR/symlinked-target.log"
|
||||
|
||||
ESCAPING_TARGET_PARENT="$TEMP_DIR/Escaping Target Parent Sparkle.framework"
|
||||
make_sparkle_version "$ESCAPING_TARGET_PARENT" B
|
||||
mv "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices" "$TEMP_DIR/displaced-xpc-services"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C/XPCServices" "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices"
|
||||
if codexbar_sparkle_signing_targets \
|
||||
"$ESCAPING_TARGET_PARENT" >"$TEMP_DIR/escaping-target-parent.out" 2>"$TEMP_DIR/escaping-target-parent.log"; then
|
||||
echo "ERROR: Sparkle signing target with an escaping parent was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "signing target resolves outside its trusted root" "$TEMP_DIR/escaping-target-parent.log"
|
||||
|
||||
ESCAPING_SINGLE="$TEMP_DIR/Escaping Single Sparkle.framework"
|
||||
mkdir -p "$ESCAPING_SINGLE/Versions"
|
||||
ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_SINGLE/Versions/B"
|
||||
if codexbar_sparkle_version_dir "$ESCAPING_SINGLE" 2>"$TEMP_DIR/escaping-single.log"; then
|
||||
echo "ERROR: Escaping single Sparkle version directory was accepted." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-single.log"
|
||||
|
||||
echo "Sparkle signing path tests passed."
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-test-sharding.XXXXXX")"
|
||||
trap 'rm -rf "${TEMP_DIR}"' EXIT
|
||||
|
||||
IFS= read -r -d '' FAKE_SWIFT_SCRIPT <<'EOF' || true
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "${FAKE_SWIFT_LOG}"
|
||||
if [[ "$*" == "test list" ]]; then
|
||||
if [[ "${FAKE_SWIFT_MODE:-success}" == "list_fail" ]]; then
|
||||
sleep 0.25
|
||||
printf 'test-list stdout marker\n'
|
||||
printf 'test-list stderr marker\n' >&2
|
||||
exit 42
|
||||
fi
|
||||
printf '%s\n' \
|
||||
"CodexBarTests.Alpha/test_one()" \
|
||||
"CodexBarTests.Alpha/test_two(argument:)" \
|
||||
"CodexBarTests.Beta/test_two" \
|
||||
"CodexBarTests.Gamma/test_three" \
|
||||
"CodexBarTests.Delta/test_four" \
|
||||
"CodexBarTests.Epsilon/test_five" \
|
||||
"CodexBarTests.Zeta/test_six" \
|
||||
"CodexBarTests.Eta/test_seven" \
|
||||
"CodexBarTests.Theta/test_eight" \
|
||||
'CodexBarTests.`top level works`()' \
|
||||
'CodexBarTests.`top/level slash works`()'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
is_group=0
|
||||
if [[ "$*" == *"|"* ]]; then
|
||||
is_group=1
|
||||
fi
|
||||
|
||||
next_group_attempt() {
|
||||
local attempt=0
|
||||
if [[ -f "${FAKE_SWIFT_STATE}" ]]; then
|
||||
read -r attempt < "${FAKE_SWIFT_STATE}"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
printf '%s\n' "${attempt}" > "${FAKE_SWIFT_STATE}"
|
||||
printf '%s\n' "${attempt}"
|
||||
}
|
||||
|
||||
case "${FAKE_SWIFT_MODE:-success}" in
|
||||
group_fail_once)
|
||||
if [[ "${is_group}" == "1" && "$(next_group_attempt)" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
group_always_fail)
|
||||
if [[ "${is_group}" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
group_timeout)
|
||||
if [[ "${is_group}" == "1" ]]; then
|
||||
sleep 2
|
||||
fi
|
||||
;;
|
||||
singleton_timeout)
|
||||
if [[ "${is_group}" == "0" ]]; then
|
||||
sleep 2
|
||||
fi
|
||||
;;
|
||||
group_fail_then_timeout)
|
||||
if [[ "${is_group}" == "1" ]]; then
|
||||
attempt="$(next_group_attempt)"
|
||||
if [[ "${attempt}" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
reset_case() {
|
||||
local name="$1"
|
||||
export FAKE_SWIFT_LOG="${TEMP_DIR}/${name}-swift.log"
|
||||
export FAKE_SWIFT_STATE="${TEMP_DIR}/${name}-state"
|
||||
export GITHUB_STEP_SUMMARY="${TEMP_DIR}/${name}-summary.md"
|
||||
rm -f "${FAKE_SWIFT_LOG}" "${FAKE_SWIFT_STATE}" "${GITHUB_STEP_SUMMARY}"
|
||||
}
|
||||
|
||||
run_harness() {
|
||||
python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" \
|
||||
"$@" \
|
||||
--swift-command /bin/bash \
|
||||
--swift-command-arg=-c \
|
||||
--swift-command-arg="${FAKE_SWIFT_SCRIPT}" \
|
||||
--swift-command-arg=fake-swift
|
||||
}
|
||||
|
||||
python3 - "${ROOT_DIR}/.github/workflows/ci.yml" <<'PY'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
workflow = pathlib.Path(sys.argv[1]).read_text()
|
||||
job_match = re.search(r"(?ms)^ swift-test-macos:\n(?P<body>.*?)(?=^ [a-zA-Z0-9_-]+:|\Z)", workflow)
|
||||
if not job_match:
|
||||
raise SystemExit("swift-test-macos job not found in CI workflow")
|
||||
|
||||
job = job_match.group("body")
|
||||
if not re.search(r"(?m)^\s+shard-index:\s+\[0,\s*1\]\s*$", job):
|
||||
raise SystemExit("swift-test-macos must run exactly two shard indexes: [0, 1]")
|
||||
if not re.search(r"(?m)^\s+shard-count:\s+\[2\]\s*$", job):
|
||||
raise SystemExit("swift-test-macos shard-count must be [2]")
|
||||
if "CODEXBAR_TEST_SHARD_INDEX=${{ matrix.shard-index }}" not in job:
|
||||
raise SystemExit("swift-test-macos must pass matrix.shard-index to Scripts/test.sh")
|
||||
if "CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }}" not in job:
|
||||
raise SystemExit("swift-test-macos must pass matrix.shard-count to Scripts/test.sh")
|
||||
PY
|
||||
|
||||
reset_case retry
|
||||
export FAKE_SWIFT_MODE=group_fail_once
|
||||
run_harness --group-size 4 --timeout 10 > "${TEMP_DIR}/retry.log"
|
||||
grep -Fq "failed with exit code 1; retrying group once" "${TEMP_DIR}/retry.log"
|
||||
grep -Fq "Swift test timing summary:" "${TEMP_DIR}/retry.log"
|
||||
grep -Fq '| Discovered selections | `10` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected selections | `10` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected groups | `3` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| First-pass successful groups | `2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
[[ "$(grep -c '^test --skip-build --no-parallel' "${FAKE_SWIFT_LOG}")" -eq 4 ]]
|
||||
grep -Fq "CodexBarTests\\.Alpha" "${FAKE_SWIFT_LOG}"
|
||||
grep -Fq "CodexBarTests\\.Beta" "${FAKE_SWIFT_LOG}"
|
||||
grep -Fq "CodexBarTests\\..*top\\ level\\ works" "${FAKE_SWIFT_LOG}"
|
||||
grep -Fq "CodexBarTests\\..*top/level\\ slash\\ works" "${FAKE_SWIFT_LOG}"
|
||||
[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 5 ]]
|
||||
|
||||
reset_case strict
|
||||
export FAKE_SWIFT_MODE=group_fail_once
|
||||
set +e
|
||||
CODEXBAR_TEST_GROUP_SIZE=4 \
|
||||
CODEXBAR_TEST_SUITE_TIMEOUT=10 \
|
||||
CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES=0 \
|
||||
"${ROOT_DIR}/Scripts/test.sh" \
|
||||
--limit-groups 1 \
|
||||
--swift-command /bin/bash \
|
||||
--swift-command-arg=-c \
|
||||
--swift-command-arg="${FAKE_SWIFT_SCRIPT}" \
|
||||
--swift-command-arg=fake-swift \
|
||||
> "${TEMP_DIR}/strict.log" 2>&1
|
||||
strict_status=$?
|
||||
set -e
|
||||
[[ "${strict_status}" -eq 1 ]]
|
||||
grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Full-group retries | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 2 ]]
|
||||
|
||||
reset_case shard-0
|
||||
export FAKE_SWIFT_MODE=success
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 > "${TEMP_DIR}/shard-0.log"
|
||||
grep -Fq '| Shard | `1/2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected selections | `6` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected groups | `2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case shard-1
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 > "${TEMP_DIR}/shard-1.log"
|
||||
grep -Fq '| Shard | `2/2` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected selections | `4` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Selected groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case shard-list-0
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 --list-only \
|
||||
> "${TEMP_DIR}/shard-list-0.log"
|
||||
reset_case shard-list-1
|
||||
run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 --list-only \
|
||||
> "${TEMP_DIR}/shard-list-1.log"
|
||||
cat "${TEMP_DIR}/shard-list-0.log" "${TEMP_DIR}/shard-list-1.log" \
|
||||
| grep -v '^Discovered ' \
|
||||
| sort > "${TEMP_DIR}/shards-combined.log"
|
||||
reset_case shard-list-all
|
||||
run_harness --group-size 4 --timeout 10 --list-only \
|
||||
| grep -v '^Discovered ' \
|
||||
| sort > "${TEMP_DIR}/shards-expected.log"
|
||||
diff -u "${TEMP_DIR}/shards-expected.log" "${TEMP_DIR}/shards-combined.log"
|
||||
|
||||
reset_case group-timeout
|
||||
export FAKE_SWIFT_MODE=group_timeout
|
||||
run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/group-timeout.log"
|
||||
grep -Fq "timed out; retrying selections one at a time" "${TEMP_DIR}/group-timeout.log"
|
||||
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case singleton-timeout
|
||||
export FAKE_SWIFT_MODE=singleton_timeout
|
||||
set +e
|
||||
run_harness --group-size 1 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/singleton-timeout.log" 2>&1
|
||||
singleton_timeout_status=$?
|
||||
set -e
|
||||
[[ "${singleton_timeout_status}" -eq 124 ]]
|
||||
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case retry-timeout
|
||||
export FAKE_SWIFT_MODE=group_fail_then_timeout
|
||||
run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/retry-timeout.log"
|
||||
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case repeated-failure
|
||||
export FAKE_SWIFT_MODE=group_always_fail
|
||||
set +e
|
||||
run_harness --group-size 4 --limit-groups 1 --timeout 10 > "${TEMP_DIR}/failure.log" 2>&1
|
||||
failure_status=$?
|
||||
set -e
|
||||
[[ "${failure_status}" -eq 1 ]]
|
||||
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
|
||||
grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
reset_case list-failure
|
||||
export FAKE_SWIFT_MODE=list_fail
|
||||
set +e
|
||||
run_harness --group-size 1 --timeout 10 > "${TEMP_DIR}/list-failure.log" 2>&1
|
||||
list_failure_status=$?
|
||||
set -e
|
||||
[[ "${list_failure_status}" -ne 0 ]]
|
||||
grep -Fq "test-list stdout marker" "${TEMP_DIR}/list-failure.log"
|
||||
grep -Fq "test-list stderr marker" "${TEMP_DIR}/list-failure.log"
|
||||
grep -Eq -- '- Discovery seconds: 0\.[1-9]' "${TEMP_DIR}/list-failure.log"
|
||||
grep -Fq '| Discovered selections | `0` |' "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
echo "Swift test sharding tests passed."
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=${1:?"usage: $0 <version>"}
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
first_line=$(grep -m1 '^## ' CHANGELOG.md | sed 's/^## //')
|
||||
if [[ "$first_line" != ${VERSION}* ]]; then
|
||||
echo "ERROR: Top CHANGELOG section is '$first_line' but expected '${VERSION} — …'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -q "^## ${VERSION} " CHANGELOG.md || {
|
||||
echo "ERROR: No section for version ${VERSION} in CHANGELOG.md" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
grep -q '^## [0-9]\+\.[0-9]\+\.[0-9].*Unreleased' CHANGELOG.md && {
|
||||
echo "ERROR: Top section still labeled Unreleased; finalize changelog first." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Changelog OK for ${VERSION}"
|
||||
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bash
|
||||
# Isolated live verification for CodexBar #1844 / PR #1848.
|
||||
# Uses only synthetic credentials under a disposable HOME and keychain.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
log() { printf '[verify-1844] %s\n' "$*"; }
|
||||
|
||||
ARTIFACT="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-1844-verify.XXXXXX")"
|
||||
chmod 700 "$ARTIFACT"
|
||||
HOME_FIXTURE="$ARTIFACT/home"
|
||||
KEYCHAIN="$ARTIFACT/claude-fixture.keychain-db"
|
||||
KEYCHAIN_PASSWORD="codexbar-1844-synthetic-fixture"
|
||||
CONFIG="$ARTIFACT/config.json"
|
||||
CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}"
|
||||
APP="${CODEXBAR_APP_BINARY:-$ROOT/CodexBar.app/Contents/MacOS/CodexBar}"
|
||||
MCP_PAYLOAD='{"mcpOAuth":{"plugin:synthetic":{"accessToken":"synthetic-mcp-token"}}}'
|
||||
EXPIRED_PAYLOAD='{"claudeAiOauth":{"accessToken":"synthetic-expired-token","expiresAt":1000,"scopes":["user:profile"],"refreshToken":"synthetic-refresh-token"}}'
|
||||
|
||||
if [[ ! -x "$CLI" ]]; then
|
||||
log "Missing packaged CLI: $CLI"
|
||||
log "Run ./Scripts/package_app.sh, then retry."
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -x "$APP" ]]; then
|
||||
log "Missing packaged app binary: $APP"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
/usr/bin/security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
log "Artifacts: $ARTIFACT"
|
||||
log "Phase 1: focused integration tests"
|
||||
{
|
||||
swift test --filter ClaudeOAuthTests
|
||||
swift test --filter ClaudeUsageTests
|
||||
swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests
|
||||
swift test --filter 'expired claude CLI owner blocks background'
|
||||
swift test --filter ClaudeOAuthCredentialsStoreSecurityCLITests
|
||||
swift test --filter ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests
|
||||
swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests
|
||||
} 2>&1 | tee "$ARTIFACT/integration-tests.log"
|
||||
log "Phase 1 passed"
|
||||
|
||||
log "Phase 2: disposable HOME, keychain, credentials, config, and Claude CLI canary"
|
||||
mkdir -p "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin"
|
||||
chmod 700 "$HOME_FIXTURE" "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library" \
|
||||
"$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin"
|
||||
printf '%s\n' "$EXPIRED_PAYLOAD" >"$HOME_FIXTURE/.claude/.credentials.json"
|
||||
chmod 600 "$HOME_FIXTURE/.claude/.credentials.json"
|
||||
printf '%s\n' '{"version":1,"providers":[{"id":"claude","enabled":true,"source":"oauth"}]}' >"$CONFIG"
|
||||
chmod 600 "$CONFIG"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf "args:" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
'printf " %q" "$@" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
'printf "\\n" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
'if [[ "$*" == "auth status --json" ]]; then printf "{\"loggedIn\":true}\\n"; exit 0; fi' \
|
||||
'if [[ "$*" == "--version" ]]; then printf "2.1.0\\n"; exit 0; fi' \
|
||||
'if IFS= read -r line; then' \
|
||||
' printf "stdin:%s\\n" "$line" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
|
||||
' if [[ "$line" == *"/status"* ]]; then printf touched >"$CODEXBAR_CLAUDE_TOUCH_CANARY"; fi' \
|
||||
'fi' \
|
||||
'exit 99' \
|
||||
>"$ARTIFACT/bin/claude"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf touched >"$CODEXBAR_OPEN_TOUCH_CANARY"' \
|
||||
'exit 99' \
|
||||
>"$ARTIFACT/bin/open"
|
||||
chmod 700 "$ARTIFACT/bin/claude" "$ARTIFACT/bin/open"
|
||||
|
||||
/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-before.txt"
|
||||
/usr/bin/security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
/usr/bin/security set-keychain-settings -t 3600 "$KEYCHAIN"
|
||||
/usr/bin/security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
/usr/bin/security add-generic-password \
|
||||
-a codexbar-verify-1844 \
|
||||
-s 'Claude Code-credentials' \
|
||||
-w "$MCP_PAYLOAD" \
|
||||
-A \
|
||||
"$KEYCHAIN"
|
||||
/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-after.txt"
|
||||
if ! cmp -s "$ARTIFACT/keychains-before.txt" "$ARTIFACT/keychains-after.txt"; then
|
||||
log "Phase 2 failed: creating the disposable keychain changed the user search list"
|
||||
exit 1
|
||||
fi
|
||||
/usr/bin/security find-generic-password \
|
||||
-s 'Claude Code-credentials' \
|
||||
-w \
|
||||
"$KEYCHAIN" >"$ARTIFACT/keychain-fixture.json"
|
||||
cmp -s "$ARTIFACT/keychain-fixture.json" <(printf '%s\n' "$MCP_PAYLOAD")
|
||||
|
||||
PROC_LOG="$ARTIFACT/e2e-processes.log"
|
||||
STDOUT="$ARTIFACT/e2e-stdout.json"
|
||||
STDERR="$ARTIFACT/e2e-stderr.jsonl"
|
||||
CANARY="$ARTIFACT/claude-status-canary"
|
||||
INVOCATIONS="$ARTIFACT/claude-invocations.log"
|
||||
OPEN_CANARY="$ARTIFACT/open-touch-canary"
|
||||
: >"$PROC_LOG"
|
||||
: >"$INVOCATIONS"
|
||||
|
||||
set +e
|
||||
(
|
||||
env \
|
||||
HOME="$HOME_FIXTURE" \
|
||||
CFFIXED_USER_HOME="$HOME_FIXTURE" \
|
||||
CODEXBAR_CONFIG="$CONFIG" \
|
||||
CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \
|
||||
CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \
|
||||
CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \
|
||||
CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \
|
||||
CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \
|
||||
CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \
|
||||
CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \
|
||||
PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
"$CLI" usage --provider claude --source oauth --format json --pretty --log-level debug \
|
||||
>"$STDOUT" 2>"$STDERR"
|
||||
) &
|
||||
PID=$!
|
||||
while kill -0 "$PID" 2>/dev/null; do
|
||||
{
|
||||
date -u +%H:%M:%S
|
||||
pgrep -P "$PID" -l 2>/dev/null || true
|
||||
} >>"$PROC_LOG"
|
||||
sleep 0.02
|
||||
done
|
||||
wait "$PID"
|
||||
CLI_STATUS=$?
|
||||
set -e
|
||||
|
||||
{
|
||||
echo "# CodexBar #1844 isolated E2E verification"
|
||||
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "candidate: $(git rev-parse HEAD)"
|
||||
echo "packaged-cli: $CLI"
|
||||
echo "cli-exit: $CLI_STATUS"
|
||||
echo "default-keychain-search-list-unchanged: yes"
|
||||
echo "real-home-referenced: no"
|
||||
echo "claude-status-canary: $([[ -e "$CANARY" ]] && echo touched || echo untouched)"
|
||||
echo "open-touch-canary: $([[ -e "$OPEN_CANARY" ]] && echo touched || echo untouched)"
|
||||
echo
|
||||
echo "## stdout"
|
||||
cat "$STDOUT"
|
||||
echo
|
||||
echo "## stderr (filtered)"
|
||||
rg -i 'mcp|delegated|expired|oauth|touch|open|only prompt|user action' "$STDERR" || true
|
||||
echo
|
||||
echo "## Claude CLI invocations"
|
||||
cat "$INVOCATIONS"
|
||||
echo
|
||||
echo "## child processes"
|
||||
cat "$PROC_LOG"
|
||||
} | tee "$ARTIFACT/E2E-REPORT.md"
|
||||
|
||||
if [[ "$CLI_STATUS" -eq 0 ]]; then
|
||||
log "Phase 2 failed: the MCP-only fixture unexpectedly produced successful OAuth usage"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$CANARY" ]]; then
|
||||
log "Phase 2 failed: delegated Claude CLI /status touch ran"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$OPEN_CANARY" ]]; then
|
||||
log "Phase 2 failed: browser/open helper ran"
|
||||
exit 1
|
||||
fi
|
||||
if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$PROC_LOG" 2>/dev/null; then
|
||||
log "Phase 2 failed: an open helper or browser was a probe child"
|
||||
exit 1
|
||||
fi
|
||||
if ! rg -qi 'MCP OAuth state only|mcpOAuthOnlyKeychain|MCP OAuth' "$STDERR" "$STDOUT"; then
|
||||
log "Phase 2 failed: expected MCP-only fail-closed message not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Phase 2 passed: exact packaged CLI failed closed without delegated /status touch or browser child"
|
||||
|
||||
log "Phase 3: isolated packaged app runtime smoke"
|
||||
APP_PROC_LOG="$ARTIFACT/app-processes.log"
|
||||
APP_STDOUT="$ARTIFACT/app-stdout.log"
|
||||
APP_STDERR="$ARTIFACT/app-stderr.log"
|
||||
: >"$APP_PROC_LOG"
|
||||
: >"$INVOCATIONS"
|
||||
(
|
||||
env \
|
||||
HOME="$HOME_FIXTURE" \
|
||||
CFFIXED_USER_HOME="$HOME_FIXTURE" \
|
||||
CODEXBAR_CONFIG="$CONFIG" \
|
||||
CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \
|
||||
CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \
|
||||
CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \
|
||||
CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \
|
||||
CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \
|
||||
CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \
|
||||
CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \
|
||||
PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
"$APP" >"$APP_STDOUT" 2>"$APP_STDERR"
|
||||
) &
|
||||
APP_PID=$!
|
||||
APP_OBSERVED_CLI=0
|
||||
POST_DISCOVERY_TICKS=0
|
||||
for _ in $(seq 1 1000); do
|
||||
if ! kill -0 "$APP_PID" 2>/dev/null; then
|
||||
log "Phase 3 failed: packaged app exited before the isolated startup smoke completed"
|
||||
wait "$APP_PID" || true
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
date -u +%H:%M:%S
|
||||
pgrep -P "$APP_PID" -l 2>/dev/null || true
|
||||
} >>"$APP_PROC_LOG"
|
||||
if rg -q '^args: --version$' "$INVOCATIONS"; then
|
||||
APP_OBSERVED_CLI=1
|
||||
POST_DISCOVERY_TICKS=$((POST_DISCOVERY_TICKS + 1))
|
||||
if [[ "$POST_DISCOVERY_TICKS" -ge 250 ]]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 0.02
|
||||
done
|
||||
kill "$APP_PID"
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
|
||||
if [[ "$APP_OBSERVED_CLI" -ne 1 ]]; then
|
||||
log "Phase 3 failed: packaged app never exercised the isolated Claude CLI fixture"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$CANARY" ]]; then
|
||||
log "Phase 3 failed: packaged app invoked delegated Claude CLI /status touch"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$OPEN_CANARY" ]]; then
|
||||
log "Phase 3 failed: packaged app invoked browser/open helper"
|
||||
exit 1
|
||||
fi
|
||||
if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$APP_PROC_LOG" 2>/dev/null; then
|
||||
log "Phase 3 failed: an open helper or browser was an app child"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo
|
||||
echo "## packaged app runtime"
|
||||
echo "app-binary: $APP"
|
||||
echo "isolated-claude-cli-discovery-observed: yes"
|
||||
echo "post-discovery-observation-seconds: 5"
|
||||
echo "app-stayed-running: yes"
|
||||
echo "claude-status-canary: untouched"
|
||||
echo "open-touch-canary: untouched"
|
||||
echo "browser-child: none"
|
||||
echo
|
||||
echo "## packaged app Claude CLI invocations"
|
||||
cat "$INVOCATIONS"
|
||||
} | tee -a "$ARTIFACT/E2E-REPORT.md"
|
||||
|
||||
log "Phase 3 passed: packaged app exercised CLI discovery without delegated /status touch or browser child"
|
||||
log "Report: $ARTIFACT/E2E-REPORT.md"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
exec "$SCRIPT_DIR/mac-release" verify-appcast "$@"
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
/// Canonical adaptive-refresh decision table shared by the app and offline replay tooling.
|
||||
/// Platform adapters normalize their thermal signals before calling this type; thresholds and
|
||||
/// delays live here only.
|
||||
package struct AdaptiveRefreshPolicyCore: Sendable {
|
||||
package struct Input: Sendable, Equatable {
|
||||
package let now: Date
|
||||
package let lastMenuOpenAt: Date?
|
||||
package let lowPowerModeEnabled: Bool
|
||||
package let thermalPressure: ThermalPressure
|
||||
|
||||
package init(
|
||||
now: Date,
|
||||
lastMenuOpenAt: Date?,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalPressure: ThermalPressure)
|
||||
{
|
||||
self.now = now
|
||||
self.lastMenuOpenAt = lastMenuOpenAt
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalPressure = thermalPressure
|
||||
}
|
||||
}
|
||||
|
||||
package enum ThermalPressure: Sendable, Equatable {
|
||||
case nominal
|
||||
case constrained
|
||||
}
|
||||
|
||||
package enum Reason: String, Sendable, Equatable {
|
||||
case recentInteraction
|
||||
case warm
|
||||
case idle
|
||||
case longIdle
|
||||
case constrained
|
||||
}
|
||||
|
||||
package struct Decision: Sendable, Equatable {
|
||||
package let delay: Duration
|
||||
package let reason: Reason
|
||||
|
||||
fileprivate init(delay: Duration, reason: Reason) {
|
||||
self.delay = delay
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
private static let recentInteractionThreshold: TimeInterval = 5 * 60
|
||||
private static let warmThreshold: TimeInterval = 60 * 60
|
||||
private static let idleThreshold: TimeInterval = 4 * 60 * 60
|
||||
|
||||
private static let recentInteractionDelay: Duration = .seconds(2 * 60)
|
||||
private static let warmDelay: Duration = .seconds(5 * 60)
|
||||
private static let idleDelay: Duration = .seconds(15 * 60)
|
||||
private static let longIdleDelay: Duration = .seconds(30 * 60)
|
||||
private static let constrainedDelay: Duration = .seconds(30 * 60)
|
||||
|
||||
/// Representative cadence for consumers that need one interval but cannot access live state.
|
||||
package static let nominalIntervalForHeuristics: TimeInterval = 5 * 60
|
||||
|
||||
package init() {}
|
||||
|
||||
package func nextDelay(for input: Input) -> Decision {
|
||||
if input.lowPowerModeEnabled || input.thermalPressure == .constrained {
|
||||
return Decision(delay: Self.constrainedDelay, reason: .constrained)
|
||||
}
|
||||
|
||||
guard let lastMenuOpenAt = input.lastMenuOpenAt else {
|
||||
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
|
||||
}
|
||||
|
||||
// A future or clock-adjusted timestamp yields a negative age, which reads as recent.
|
||||
let age = input.now.timeIntervalSince(lastMenuOpenAt)
|
||||
|
||||
if age <= Self.recentInteractionThreshold {
|
||||
return Decision(delay: Self.recentInteractionDelay, reason: .recentInteraction)
|
||||
}
|
||||
if age <= Self.warmThreshold {
|
||||
return Decision(delay: Self.warmDelay, reason: .warm)
|
||||
}
|
||||
if age < Self.idleThreshold {
|
||||
return Decision(delay: Self.idleDelay, reason: .idle)
|
||||
}
|
||||
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
|
||||
enum ReplayPolicyName: String, CaseIterable, Sendable {
|
||||
case adaptive
|
||||
case adaptiveActivity = "adaptive-activity"
|
||||
case fixed2Minutes = "fixed-2m"
|
||||
case fixed5Minutes = "fixed-5m"
|
||||
case fixed15Minutes = "fixed-15m"
|
||||
case fixed30Minutes = "fixed-30m"
|
||||
case manual
|
||||
|
||||
var policy: any ReplayPolicy {
|
||||
switch self {
|
||||
case .adaptive:
|
||||
AdaptiveReplayPolicy()
|
||||
case .adaptiveActivity:
|
||||
CodingActivityAdaptivePolicy()
|
||||
case .fixed2Minutes:
|
||||
FixedIntervalPolicy(minutes: 2)
|
||||
case .fixed5Minutes:
|
||||
FixedIntervalPolicy(minutes: 5)
|
||||
case .fixed15Minutes:
|
||||
FixedIntervalPolicy(minutes: 15)
|
||||
case .fixed30Minutes:
|
||||
FixedIntervalPolicy(minutes: 30)
|
||||
case .manual:
|
||||
ManualPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
static var expectedValues: String {
|
||||
allCases.map(\.rawValue).joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
enum CLIArguments {
|
||||
case run(
|
||||
tracePath: String,
|
||||
policyNames: [ReplayPolicyName],
|
||||
jsonOutput: Bool,
|
||||
gapGraceSeconds: TimeInterval?)
|
||||
case help(exitCode: Int32)
|
||||
case invalid(message: String)
|
||||
|
||||
static func parse(_ arguments: [String]) -> Self {
|
||||
if arguments.contains("-h") || arguments.contains("--help") {
|
||||
return .help(exitCode: EXIT_SUCCESS)
|
||||
}
|
||||
|
||||
var tracePath: String?
|
||||
var policyNames: [ReplayPolicyName] = []
|
||||
var jsonOutput = false
|
||||
var gapGraceSeconds: TimeInterval? = ReplayTraceSegmenter.defaultGraceSeconds
|
||||
var index = 0
|
||||
while index < arguments.count {
|
||||
let argument = arguments[index]
|
||||
switch argument {
|
||||
case "--json":
|
||||
jsonOutput = true
|
||||
case "--raw-wall-clock":
|
||||
gapGraceSeconds = nil
|
||||
case "--gap-grace":
|
||||
index += 1
|
||||
guard index < arguments.count,
|
||||
let seconds = TimeInterval(arguments[index]),
|
||||
seconds >= 0,
|
||||
seconds.isFinite
|
||||
else { return .invalid(message: "--gap-grace requires non-negative finite seconds") }
|
||||
gapGraceSeconds = seconds
|
||||
case "--policy":
|
||||
index += 1
|
||||
guard index < arguments.count else { return .invalid(message: "--policy requires a value") }
|
||||
let rawPolicyName = arguments[index]
|
||||
guard let policyName = ReplayPolicyName(rawValue: rawPolicyName) else {
|
||||
return .invalid(
|
||||
message: "unknown policy '\(rawPolicyName)' (expected: \(ReplayPolicyName.expectedValues))")
|
||||
}
|
||||
policyNames.append(policyName)
|
||||
default:
|
||||
guard tracePath == nil else {
|
||||
return .invalid(message: "unexpected argument '\(argument)'")
|
||||
}
|
||||
tracePath = argument
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
guard let tracePath else {
|
||||
return .help(exitCode: EXIT_FAILURE)
|
||||
}
|
||||
return .run(
|
||||
tracePath: tracePath,
|
||||
policyNames: policyNames.isEmpty ? ReplayPolicyName.allCases : policyNames,
|
||||
jsonOutput: jsonOutput,
|
||||
gapGraceSeconds: gapGraceSeconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
|
||||
/// Thin CLI shell over `AdaptiveReplayKit`: parses a trace path and a policy name, runs the
|
||||
/// replay, and prints the resulting `ReplayMetrics`. All parsing/replay/metrics logic lives in
|
||||
/// the library — this file only routes arguments to it and formats the result.
|
||||
enum AdaptiveReplayCLI {
|
||||
static func main() {
|
||||
let arguments = CLIArguments.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
|
||||
switch arguments {
|
||||
case let .help(exitCode):
|
||||
print(Self.helpText)
|
||||
exit(exitCode)
|
||||
case let .invalid(message):
|
||||
FileHandle.standardError.write(Data("error: \(message)\n\n\(Self.helpText)\n".utf8))
|
||||
exit(EXIT_FAILURE)
|
||||
case let .run(tracePath, policyNames, jsonOutput, gapGraceSeconds):
|
||||
Self.run(
|
||||
tracePath: tracePath,
|
||||
policyNames: policyNames,
|
||||
jsonOutput: jsonOutput,
|
||||
gapGraceSeconds: gapGraceSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
private static func run(
|
||||
tracePath: String,
|
||||
policyNames: [ReplayPolicyName],
|
||||
jsonOutput: Bool,
|
||||
gapGraceSeconds: TimeInterval?)
|
||||
{
|
||||
let records: [AdaptiveRefreshTraceRecord]
|
||||
do {
|
||||
records = try AdaptiveRefreshTraceParser.parse(contentsOf: URL(fileURLWithPath: tracePath))
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("error: failed to parse trace: \(error)\n".utf8))
|
||||
exit(EXIT_FAILURE)
|
||||
}
|
||||
|
||||
let policies = policyNames.map(\.policy)
|
||||
|
||||
let results = policies.map { policy in
|
||||
gapGraceSeconds.map {
|
||||
ReplayEngine.runSegmented(trace: records, policy: policy, graceSeconds: $0)
|
||||
} ?? ReplayEngine.run(trace: records, policy: policy)
|
||||
}
|
||||
let activityCoverage = ActivityCoverageStats.compute(from: records)
|
||||
let recordedScheduleAudit = RecordedScheduleAuditor.audit(records)
|
||||
|
||||
if jsonOutput {
|
||||
print(Self.renderJSON(
|
||||
results,
|
||||
activityCoverage: activityCoverage,
|
||||
recordedScheduleAudit: recordedScheduleAudit,
|
||||
gapGraceSeconds: gapGraceSeconds))
|
||||
} else {
|
||||
print(Self.renderTable(results))
|
||||
print(Self.renderActivityCoverage(activityCoverage))
|
||||
print(Self.renderRecordedScheduleAudit(recordedScheduleAudit))
|
||||
if let gapGraceSeconds, let first = results.first {
|
||||
print(String(
|
||||
format: "segmentation: %d segments, %.2fh excluded (legacy heuristic, %.0fs grace)",
|
||||
first.segmentCount,
|
||||
first.excludedGapSeconds / 3600,
|
||||
gapGraceSeconds))
|
||||
} else {
|
||||
print("segmentation: disabled (raw wall clock)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func renderRecordedScheduleAudit(_ audit: RecordedScheduleAudit) -> String {
|
||||
"recorded schedule: \(audit.recordedAdvanceCount) advances, "
|
||||
+ "\(audit.acceptedEvaluationCount)/\(audit.evaluatedCount) evaluations accepted, "
|
||||
+ "payload=\(audit.payloadMismatchCount) decision=\(audit.decisionMismatchCount) "
|
||||
+ "menu-link=\(audit.menuLinkMismatchCount) mismatches, "
|
||||
+ "ambiguous=\(audit.ambiguousComparisonCount)"
|
||||
}
|
||||
|
||||
/// Reports coverage of optional activity observations already present in the input trace.
|
||||
private static func renderActivityCoverage(_ stats: ActivityCoverageStats) -> String {
|
||||
guard stats.decisionCount > 0 else {
|
||||
return "activity telemetry: no decision events in trace"
|
||||
}
|
||||
let sampledSummary = String(
|
||||
format: "%d/%d decisions sampled (%.0f%%)",
|
||||
stats.sampledCount,
|
||||
stats.decisionCount,
|
||||
stats.sampledFraction * 100)
|
||||
let activeSummary = String(
|
||||
format: "%d/%d active coding at decision time (%.0f%%)",
|
||||
stats.activeCount,
|
||||
stats.sampledCount,
|
||||
stats.activeFraction * 100)
|
||||
return "activity telemetry: \(sampledSummary), \(activeSummary)"
|
||||
}
|
||||
|
||||
private static func renderTable(_ results: [ReplayMetrics]) -> String {
|
||||
var lines: [String] = []
|
||||
let header = [
|
||||
"policy", "refreshes", "per24h", "sim advances", "active >5m", "staleness p50",
|
||||
"staleness p95", "constrained ok",
|
||||
]
|
||||
lines.append(header.joined(separator: "\t"))
|
||||
for metrics in results {
|
||||
let staleness = metrics.stalenessAtMenuOpen
|
||||
lines.append([
|
||||
metrics.policyName,
|
||||
String(metrics.totalRefreshCount),
|
||||
String(format: "%.2f", metrics.refreshCountPer24h),
|
||||
String(metrics.interactionAdvanceCount),
|
||||
"\(metrics.codingActiveDelayViolationCount)/\(metrics.codingActiveDecisionCount)",
|
||||
staleness.map { String(format: "%.0fs", $0.median) } ?? "n/a",
|
||||
staleness.map { String(format: "%.0fs", $0.p95) } ?? "n/a",
|
||||
metrics.constrainedCompliance
|
||||
.isCompliant ? "yes" : "NO (\(metrics.constrainedCompliance.violationCount))",
|
||||
].joined(separator: "\t"))
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func renderJSON(
|
||||
_ results: [ReplayMetrics],
|
||||
activityCoverage: ActivityCoverageStats,
|
||||
recordedScheduleAudit: RecordedScheduleAudit,
|
||||
gapGraceSeconds: TimeInterval?) -> String
|
||||
{
|
||||
let policies = results.map { metrics -> [String: Any] in
|
||||
var dict: [String: Any] = [
|
||||
"policy": metrics.policyName,
|
||||
"simulatedSpanSeconds": metrics.simulatedSpanSeconds,
|
||||
"totalRefreshCount": metrics.totalRefreshCount,
|
||||
"refreshCountPer24h": metrics.refreshCountPer24h,
|
||||
"interactionAdvanceCount": metrics.interactionAdvanceCount,
|
||||
"codingActiveDecisionCount": metrics.codingActiveDecisionCount,
|
||||
"codingActiveDelayViolationCount": metrics.codingActiveDelayViolationCount,
|
||||
"segmentCount": metrics.segmentCount,
|
||||
"excludedGapSeconds": metrics.excludedGapSeconds,
|
||||
"boundaryCensoredMenuOpenCount": metrics.boundaryCensoredMenuOpenCount,
|
||||
"constrainedDecisionCount": metrics.constrainedCompliance.constrainedDecisionCount,
|
||||
"constrainedViolationCount": metrics.constrainedCompliance.violationCount,
|
||||
"constrainedCompliant": metrics.constrainedCompliance.isCompliant,
|
||||
]
|
||||
if let staleness = metrics.stalenessAtMenuOpen {
|
||||
dict["stalenessMeanSeconds"] = staleness.mean
|
||||
dict["stalenessMedianSeconds"] = staleness.median
|
||||
dict["stalenessP95Seconds"] = staleness.p95
|
||||
dict["stalenessSampleCount"] = staleness.sampleCount
|
||||
}
|
||||
return dict
|
||||
}
|
||||
let segmentation: [String: Any] = [
|
||||
"mode": gapGraceSeconds == nil ? "rawWallClock" : "legacyGapHeuristic",
|
||||
"gapGraceSeconds": gapGraceSeconds.map { $0 as Any } ?? NSNull(),
|
||||
]
|
||||
let payload: [String: Any] = [
|
||||
"policies": policies,
|
||||
"activityCoverage": [
|
||||
"decisionCount": activityCoverage.decisionCount,
|
||||
"sampledCount": activityCoverage.sampledCount,
|
||||
"activeCount": activityCoverage.activeCount,
|
||||
"sampledFraction": activityCoverage.sampledFraction,
|
||||
"activeFraction": activityCoverage.activeFraction,
|
||||
],
|
||||
"recordedScheduleAudit": [
|
||||
"recordedAdvanceCount": recordedScheduleAudit.recordedAdvanceCount,
|
||||
"evaluatedCount": recordedScheduleAudit.evaluatedCount,
|
||||
"acceptedEvaluationCount": recordedScheduleAudit.acceptedEvaluationCount,
|
||||
"rejectedEvaluationCount": recordedScheduleAudit.rejectedEvaluationCount,
|
||||
"payloadMismatchCount": recordedScheduleAudit.payloadMismatchCount,
|
||||
"decisionMismatchCount": recordedScheduleAudit.decisionMismatchCount,
|
||||
"menuLinkMismatchCount": recordedScheduleAudit.menuLinkMismatchCount,
|
||||
"ambiguousComparisonCount": recordedScheduleAudit.ambiguousComparisonCount,
|
||||
"isValid": recordedScheduleAudit.isValid,
|
||||
],
|
||||
"segmentation": segmentation,
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(
|
||||
withJSONObject: payload,
|
||||
options: [.prettyPrinted, .sortedKeys]),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return "{}"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static let helpText = """
|
||||
Usage: AdaptiveReplayCLI <trace.jsonl> [--policy <name>]... [--gap-grace <seconds>] [--raw-wall-clock] [--json]
|
||||
|
||||
Replays a JSONL adaptive-refresh trace against one or more refresh-timing policies and prints
|
||||
per-policy metrics over automatically segmented observed time. Simulated advances are
|
||||
counterfactual policy events; recorded live schedule evaluations are audited separately.
|
||||
|
||||
Policies:
|
||||
adaptive The shared production adaptive policy. Advances on menu-open interactions,
|
||||
same as UsageStore.noteMenuOpened(at:).
|
||||
adaptive-activity Experimental adaptive table capped at 5m after observed coding activity.
|
||||
fixed-2m Fixed 2 minute cadence. Unaffected by menu-open interactions.
|
||||
fixed-5m Fixed 5 minute cadence.
|
||||
fixed-15m Fixed 15 minute cadence.
|
||||
fixed-30m Fixed 30 minute cadence.
|
||||
manual Never refreshes (degenerate floor).
|
||||
|
||||
Defaults to comparing all seven policies when --policy is omitted.
|
||||
|
||||
Options:
|
||||
--policy <name> Restrict to one listed policy; repeat to compare a specific subset.
|
||||
--gap-grace <s> Split legacy gaps this many seconds after the last timer deadline (default 300).
|
||||
--raw-wall-clock Disable gap segmentation; useful only for auditing the old behavior.
|
||||
--json Print a machine-readable report including replay, activity, and audit data.
|
||||
-h, --help Print this help text.
|
||||
"""
|
||||
}
|
||||
|
||||
AdaptiveReplayCLI.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// Informational summary of optional coding-activity observations in a trace's `decision` events.
|
||||
/// It reports how many decisions carried activity data and how many sampled decisions were below
|
||||
/// `activeThresholdSeconds` for either CLI.
|
||||
public struct ActivityCoverageStats: Sendable, Equatable {
|
||||
public let decisionCount: Int
|
||||
public let sampledCount: Int
|
||||
public let activeCount: Int
|
||||
|
||||
public init(decisionCount: Int, sampledCount: Int, activeCount: Int) {
|
||||
self.decisionCount = decisionCount
|
||||
self.sampledCount = sampledCount
|
||||
self.activeCount = activeCount
|
||||
}
|
||||
|
||||
/// Fraction of `decision` events that carried at least one non-nil activity field.
|
||||
public var sampledFraction: Double {
|
||||
self.decisionCount == 0 ? 0 : Double(self.sampledCount) / Double(self.decisionCount)
|
||||
}
|
||||
|
||||
/// Fraction of the *sampled* decisions (not all decisions) that looked like active coding.
|
||||
public var activeFraction: Double {
|
||||
self.sampledCount == 0 ? 0 : Double(self.activeCount) / Double(self.sampledCount)
|
||||
}
|
||||
|
||||
/// - Parameter activeThresholdSeconds: below this many seconds since the newest transcript
|
||||
/// write, a CLI counts as "active coding at decision time". Defaults to 5 minutes.
|
||||
public static func compute(
|
||||
from records: [AdaptiveRefreshTraceRecord],
|
||||
activeThresholdSeconds: TimeInterval = 300) -> Self
|
||||
{
|
||||
var sampledCount = 0
|
||||
var activeCount = 0
|
||||
var decisionCount = 0
|
||||
for record in records where record.kind == .decision {
|
||||
decisionCount += 1
|
||||
let codexSeconds = record.codexActivitySeconds
|
||||
let claudeSeconds = record.claudeActivitySeconds
|
||||
guard codexSeconds != nil || claudeSeconds != nil else { continue }
|
||||
sampledCount += 1
|
||||
let isActive = (codexSeconds ?? .infinity) < activeThresholdSeconds
|
||||
|| (claudeSeconds ?? .infinity) < activeThresholdSeconds
|
||||
if isActive {
|
||||
activeCount += 1
|
||||
}
|
||||
}
|
||||
return Self(decisionCount: decisionCount, sampledCount: sampledCount, activeCount: activeCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
|
||||
/// The event kinds a trace records. `decision` events capture a full policy tick (the
|
||||
/// signals it saw plus what it chose). `menuOpen` and `refreshCompleted` capture the two
|
||||
/// ground-truth events the replay engine anchors a simulation to, independent of any candidate
|
||||
/// policy. `timerAdvanced` captures the one place live behavior *isn't* a plain tick loop: when
|
||||
/// opening the menu makes `UsageStore.noteMenuOpened(at:)` pull the next adaptive refresh forward
|
||||
/// (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`). Recording it separately
|
||||
/// from `decision` lets a trace answer "did an advance happen, and to when" without relying on
|
||||
/// fragile inference from decision-timestamp gaps.
|
||||
public enum AdaptiveRefreshTraceEventKind: String, Sendable, Codable {
|
||||
case decision
|
||||
case menuOpen
|
||||
case refreshCompleted
|
||||
case timerAdvanced
|
||||
/// Every live advance comparison, including the cases correctly rejected because the current
|
||||
/// timer was already earlier. This is distinct from counterfactual replay advances.
|
||||
case timerAdvanceEvaluated
|
||||
}
|
||||
|
||||
/// One line of a JSONL adaptive-refresh trace. Field presence depends on `kind`: `decision`
|
||||
/// records populate `menuAgeSeconds`, `lowPowerModeEnabled`, `thermalState`, `reason`, and
|
||||
/// `delaySeconds`, plus optional activity observations supplied by the input trace
|
||||
/// (`codexActivitySeconds`/`claudeActivitySeconds`, the seconds-since-newest-transcript fields,
|
||||
/// and the per-file intensity fields alongside them); timer advance records populate
|
||||
/// `previousScheduledAt`, `candidateScheduledAt`, `reason`, and `delaySeconds`; `menuOpen` and
|
||||
/// `refreshCompleted` carry only `kind` and `timestamp`.
|
||||
public struct AdaptiveRefreshTraceRecord: Sendable, Codable, Equatable {
|
||||
public let kind: AdaptiveRefreshTraceEventKind
|
||||
public let timestamp: Date
|
||||
public let menuAgeSeconds: TimeInterval?
|
||||
public let lowPowerModeEnabled: Bool?
|
||||
public let thermalState: ReplayThermalState?
|
||||
public let reason: String?
|
||||
public let delaySeconds: TimeInterval?
|
||||
/// Timer advance records only: the adaptive timer's scheduled refresh time before the
|
||||
/// comparison, or `nil` when no refresh had been scheduled yet (matches
|
||||
/// `UsageStore.shouldAdvanceAdaptiveTimer`'s "always advance when nothing is scheduled" rule).
|
||||
public let previousScheduledAt: Date?
|
||||
/// Timer advance records only: the candidate refresh time, i.e. the menu-open timestamp plus
|
||||
/// the freshly computed decision's delay.
|
||||
public let candidateScheduledAt: Date?
|
||||
/// `timerAdvanceEvaluated` only: whether the live schedule comparison accepted the candidate.
|
||||
public let timerAdvanceAccepted: Bool?
|
||||
/// `timerAdvanceEvaluated` only: `previousScheduledAt - candidateScheduledAt`, captured before
|
||||
/// whole-second ISO-8601 serialization. Positive means the candidate was earlier. Optional for
|
||||
/// compatibility with traces recorded before exact comparison deltas were added.
|
||||
public let scheduleLeadSeconds: TimeInterval?
|
||||
/// `timerAdvanceEvaluated` only: whether another refresh was in flight at comparison time.
|
||||
public let refreshInFlight: Bool?
|
||||
/// `decision` only: seconds since the newest observed Codex session transcript modification,
|
||||
/// or `nil` when unavailable. Optional so old trace lines without this field keep decoding.
|
||||
public let codexActivitySeconds: TimeInterval?
|
||||
/// `decision` only: the Claude Code counterpart of `codexActivitySeconds`.
|
||||
public let claudeActivitySeconds: TimeInterval?
|
||||
/// `decision` only: how long the newest Codex transcript has been
|
||||
/// growing (its mtime minus its creationDate), or `nil` when unavailable. Not a separate
|
||||
/// session-age field — age is `codexActivitySeconds` + `codexSessionDurationSeconds`.
|
||||
public let codexSessionDurationSeconds: TimeInterval?
|
||||
/// `decision` only: the Claude Code counterpart of `codexSessionDurationSeconds`.
|
||||
public let claudeSessionDurationSeconds: TimeInterval?
|
||||
/// `decision` only: size in bytes of the newest Codex transcript, as a stateless raw value.
|
||||
public let codexTranscriptBytes: Int64?
|
||||
/// `decision` only: the Claude Code counterpart of `codexTranscriptBytes`.
|
||||
public let claudeTranscriptBytes: Int64?
|
||||
/// `decision` only: count of Codex `.jsonl` transcripts modified in the observation window.
|
||||
public let codexActiveTranscriptCount: Int?
|
||||
/// `decision` only: the Claude Code counterpart of `codexActiveTranscriptCount`.
|
||||
public let claudeActiveTranscriptCount: Int?
|
||||
|
||||
public init(
|
||||
kind: AdaptiveRefreshTraceEventKind,
|
||||
timestamp: Date,
|
||||
menuAgeSeconds: TimeInterval? = nil,
|
||||
lowPowerModeEnabled: Bool? = nil,
|
||||
thermalState: ReplayThermalState? = nil,
|
||||
reason: String? = nil,
|
||||
delaySeconds: TimeInterval? = nil,
|
||||
previousScheduledAt: Date? = nil,
|
||||
candidateScheduledAt: Date? = nil,
|
||||
timerAdvanceAccepted: Bool? = nil,
|
||||
scheduleLeadSeconds: TimeInterval? = nil,
|
||||
refreshInFlight: Bool? = nil,
|
||||
codexActivitySeconds: TimeInterval? = nil,
|
||||
claudeActivitySeconds: TimeInterval? = nil,
|
||||
codexSessionDurationSeconds: TimeInterval? = nil,
|
||||
claudeSessionDurationSeconds: TimeInterval? = nil,
|
||||
codexTranscriptBytes: Int64? = nil,
|
||||
claudeTranscriptBytes: Int64? = nil,
|
||||
codexActiveTranscriptCount: Int? = nil,
|
||||
claudeActiveTranscriptCount: Int? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.timestamp = timestamp
|
||||
self.menuAgeSeconds = menuAgeSeconds
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalState = thermalState
|
||||
self.reason = reason
|
||||
self.delaySeconds = delaySeconds
|
||||
self.previousScheduledAt = previousScheduledAt
|
||||
self.candidateScheduledAt = candidateScheduledAt
|
||||
self.timerAdvanceAccepted = timerAdvanceAccepted
|
||||
self.scheduleLeadSeconds = scheduleLeadSeconds
|
||||
self.refreshInFlight = refreshInFlight
|
||||
self.codexActivitySeconds = codexActivitySeconds
|
||||
self.claudeActivitySeconds = claudeActivitySeconds
|
||||
self.codexSessionDurationSeconds = codexSessionDurationSeconds
|
||||
self.claudeSessionDurationSeconds = claudeSessionDurationSeconds
|
||||
self.codexTranscriptBytes = codexTranscriptBytes
|
||||
self.claudeTranscriptBytes = claudeTranscriptBytes
|
||||
self.codexActiveTranscriptCount = codexActiveTranscriptCount
|
||||
self.claudeActiveTranscriptCount = claudeActiveTranscriptCount
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public static func decision(
|
||||
timestamp: Date,
|
||||
menuAgeSeconds: TimeInterval?,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalState: ReplayThermalState,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval,
|
||||
codexActivitySeconds: TimeInterval? = nil,
|
||||
claudeActivitySeconds: TimeInterval? = nil,
|
||||
codexSessionDurationSeconds: TimeInterval? = nil,
|
||||
claudeSessionDurationSeconds: TimeInterval? = nil,
|
||||
codexTranscriptBytes: Int64? = nil,
|
||||
claudeTranscriptBytes: Int64? = nil,
|
||||
codexActiveTranscriptCount: Int? = nil,
|
||||
claudeActiveTranscriptCount: Int? = nil) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .decision,
|
||||
timestamp: timestamp,
|
||||
menuAgeSeconds: menuAgeSeconds,
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalState: thermalState,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
codexActivitySeconds: codexActivitySeconds,
|
||||
claudeActivitySeconds: claudeActivitySeconds,
|
||||
codexSessionDurationSeconds: codexSessionDurationSeconds,
|
||||
claudeSessionDurationSeconds: claudeSessionDurationSeconds,
|
||||
codexTranscriptBytes: codexTranscriptBytes,
|
||||
claudeTranscriptBytes: claudeTranscriptBytes,
|
||||
codexActiveTranscriptCount: codexActiveTranscriptCount,
|
||||
claudeActiveTranscriptCount: claudeActiveTranscriptCount)
|
||||
}
|
||||
|
||||
public static func menuOpen(timestamp: Date) -> Self {
|
||||
Self(kind: .menuOpen, timestamp: timestamp)
|
||||
}
|
||||
|
||||
public static func refreshCompleted(timestamp: Date) -> Self {
|
||||
Self(kind: .refreshCompleted, timestamp: timestamp)
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - timestamp: When the menu open that triggered the advance occurred.
|
||||
/// - previousScheduledAt: The timer's scheduled refresh time immediately before the advance.
|
||||
/// - candidateScheduledAt: The refresh time the timer advanced to (`timestamp + delaySeconds`).
|
||||
/// - reason: The freshly computed decision's reason (e.g. `"recentInteraction"`).
|
||||
/// - delaySeconds: The freshly computed decision's delay.
|
||||
public static func timerAdvanced(
|
||||
timestamp: Date,
|
||||
previousScheduledAt: Date?,
|
||||
candidateScheduledAt: Date,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .timerAdvanced,
|
||||
timestamp: timestamp,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
previousScheduledAt: previousScheduledAt,
|
||||
candidateScheduledAt: candidateScheduledAt)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public static func timerAdvanceEvaluated(
|
||||
timestamp: Date,
|
||||
previousScheduledAt: Date?,
|
||||
candidateScheduledAt: Date,
|
||||
reason: String,
|
||||
delaySeconds: TimeInterval,
|
||||
accepted: Bool,
|
||||
refreshInFlight: Bool) -> Self
|
||||
{
|
||||
Self(
|
||||
kind: .timerAdvanceEvaluated,
|
||||
timestamp: timestamp,
|
||||
reason: reason,
|
||||
delaySeconds: delaySeconds,
|
||||
previousScheduledAt: previousScheduledAt,
|
||||
candidateScheduledAt: candidateScheduledAt,
|
||||
timerAdvanceAccepted: accepted,
|
||||
scheduleLeadSeconds: previousScheduledAt.map { $0.timeIntervalSince(candidateScheduledAt) },
|
||||
refreshInFlight: refreshInFlight)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// A malformed trace line, with enough context to find and fix it.
|
||||
public struct AdaptiveRefreshTraceParseError: Error, Sendable, Equatable, CustomStringConvertible {
|
||||
public let lineNumber: Int
|
||||
public let content: String
|
||||
public let underlyingDescription: String
|
||||
|
||||
public init(lineNumber: Int, content: String, underlyingDescription: String) {
|
||||
self.lineNumber = lineNumber
|
||||
self.content = content
|
||||
self.underlyingDescription = underlyingDescription
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"trace line \(self.lineNumber) is malformed: \(self.underlyingDescription) (content: \(self.content))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses newline-delimited JSON adaptive-refresh traces.
|
||||
///
|
||||
/// Deliberate choice: a malformed line **fails the whole parse** rather than being silently
|
||||
/// skipped. A trace is acceptance evidence — if a line is corrupt (truncated write, disk-full
|
||||
/// mid-append, hand-edited fixture with a typo), the honest answer is "this trace is untrustworthy
|
||||
/// as a whole", not "here are metrics computed from however much of it happened to parse". A
|
||||
/// silently-shortened trace would still produce a superficially plausible replay report, which is
|
||||
/// worse than a loud failure: it hides exactly the kind of gap that would bias staleness/refresh
|
||||
/// counts. Callers that genuinely want best-effort parsing can catch the error and fall back to
|
||||
/// `AdaptiveRefreshTraceParser.parseTolerantly`, which skips bad lines and returns what parsed.
|
||||
public enum AdaptiveRefreshTraceParser {
|
||||
public static func parse(_ text: String) throws -> [AdaptiveRefreshTraceRecord] {
|
||||
let decoder = Self.makeDecoder()
|
||||
var records: [AdaptiveRefreshTraceRecord] = []
|
||||
for (index, line) in text.split(
|
||||
omittingEmptySubsequences: false,
|
||||
whereSeparator: \.isNewline).enumerated()
|
||||
{
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
guard let data = trimmed.data(using: .utf8) else {
|
||||
throw AdaptiveRefreshTraceParseError(
|
||||
lineNumber: index + 1,
|
||||
content: trimmed,
|
||||
underlyingDescription: "not valid UTF-8")
|
||||
}
|
||||
do {
|
||||
try records.append(decoder.decode(AdaptiveRefreshTraceRecord.self, from: data))
|
||||
} catch {
|
||||
throw AdaptiveRefreshTraceParseError(
|
||||
lineNumber: index + 1,
|
||||
content: trimmed,
|
||||
underlyingDescription: String(describing: error))
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
public static func parse(contentsOf url: URL) throws -> [AdaptiveRefreshTraceRecord] {
|
||||
let text = try String(contentsOf: url, encoding: .utf8)
|
||||
return try self.parse(text)
|
||||
}
|
||||
|
||||
/// Best-effort variant: skips lines that fail to parse instead of throwing. Not the default —
|
||||
/// see the type-level documentation for why silent skipping is the wrong default for
|
||||
/// acceptance-evidence traces. Exists for callers (future exploratory tooling) that explicitly
|
||||
/// want partial data over none.
|
||||
public static func parseTolerantly(_ text: String) -> [AdaptiveRefreshTraceRecord] {
|
||||
let decoder = Self.makeDecoder()
|
||||
var records: [AdaptiveRefreshTraceRecord] = []
|
||||
for line in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { continue }
|
||||
if let record = try? decoder.decode(AdaptiveRefreshTraceRecord.self, from: data) {
|
||||
records.append(record)
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private static func makeDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import AdaptiveRefreshCore
|
||||
import Foundation
|
||||
|
||||
/// Replay adapter for the same canonical policy core used by the CodexBar app.
|
||||
public struct AdaptiveReplayPolicy: ReplayPolicy, Sendable {
|
||||
public let name = "adaptive"
|
||||
|
||||
/// Matches `UsageStore.noteMenuOpened(at:)`'s adaptive-only advance guard: this is the one
|
||||
/// baseline that actually models the interaction-advance path, so it is the only one that
|
||||
/// overrides the protocol's `false` default.
|
||||
public let advancesOnInteraction = true
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
|
||||
now: input.now,
|
||||
lastMenuOpenAt: input.lastMenuOpenAt,
|
||||
lowPowerModeEnabled: input.lowPowerModeEnabled,
|
||||
thermalPressure: input.thermalState.isConstrained ? .constrained : .nominal))
|
||||
return ReplayPolicyDecision(
|
||||
delaySeconds: TimeInterval(decision.delay.components.seconds),
|
||||
reason: decision.reason.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed-cadence baseline: always waits the same interval, regardless of signals. Used to
|
||||
/// compare the adaptive policy against the flat refresh frequencies CodexBar also offers
|
||||
/// (2/5/15/30 minutes). Never advances on interaction (`advancesOnInteraction` stays the protocol
|
||||
/// default of `false`), matching the real app: fixed-cadence refresh frequencies never wire up
|
||||
/// `noteMenuOpened`'s advance check.
|
||||
public struct FixedIntervalPolicy: ReplayPolicy, Sendable {
|
||||
public let name: String
|
||||
private let intervalSeconds: TimeInterval
|
||||
|
||||
public init(minutes: Int) {
|
||||
self.name = "fixed-\(minutes)m"
|
||||
self.intervalSeconds = TimeInterval(minutes) * 60
|
||||
}
|
||||
|
||||
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
ReplayPolicyDecision(delaySeconds: self.intervalSeconds, reason: "fixed")
|
||||
}
|
||||
}
|
||||
|
||||
/// The degenerate floor: never schedules a refresh. A trace replayed against this policy always
|
||||
/// reports zero refreshes, which is the point — it establishes the worst-case staleness bound the
|
||||
/// other policies are compared against.
|
||||
public struct ManualPolicy: ReplayPolicy, Sendable {
|
||||
public let name = "manual"
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
ReplayPolicyDecision(delaySeconds: nil, reason: "manual")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// Replay-only candidate used to test whether stat-only coding activity can close the accepted
|
||||
/// "active work is never slower than five minutes" gap. It is not a production policy approval.
|
||||
public struct CodingActivityAdaptivePolicy: ReplayPolicy, Sendable {
|
||||
public let name = "adaptive-activity"
|
||||
public let advancesOnInteraction = true
|
||||
|
||||
private let base = AdaptiveReplayPolicy()
|
||||
private static let activeThreshold: TimeInterval = 5 * 60
|
||||
private static let activeDelayCap: TimeInterval = 5 * 60
|
||||
|
||||
public init() {}
|
||||
|
||||
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
|
||||
let baseDecision = self.base.decide(input)
|
||||
guard !input.isConstrained,
|
||||
let activityAge = input.codingActivityAgeSeconds,
|
||||
activityAge < Self.activeThreshold,
|
||||
let baseDelay = baseDecision.delaySeconds,
|
||||
baseDelay > Self.activeDelayCap
|
||||
else {
|
||||
return baseDecision
|
||||
}
|
||||
return ReplayPolicyDecision(delaySeconds: Self.activeDelayCap, reason: "codingActivity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# AdaptiveReplayKit
|
||||
|
||||
`AdaptiveReplayKit` is an offline harness for comparing refresh-timing policies against an
|
||||
explicit JSONL trace. `AdaptiveReplayCLI` is the command-line wrapper around the library.
|
||||
|
||||
## Scope
|
||||
|
||||
The replay targets do not import `CodexBar` or `CodexBarCore`; they share only the package-internal,
|
||||
Foundation-only `AdaptiveRefreshCore` target with the app. They do not record app behavior, scan
|
||||
Codex or Claude transcript directories, write trace files, call providers, or change the production
|
||||
refresh policy. Trace capture and lifecycle management are deliberately outside this PR; callers
|
||||
provide an existing trace path to the CLI.
|
||||
|
||||
Optional activity fields in the trace schema are inputs only. The replay kit never discovers or
|
||||
collects them. Old records without those fields continue to decode.
|
||||
|
||||
## Components
|
||||
|
||||
- `AdaptiveRefreshTrace.swift` defines the version-tolerant trace schema.
|
||||
- `AdaptiveRefreshTraceParser.swift` parses JSONL strictly by default. The tolerant entry point is
|
||||
available for exploratory work that explicitly accepts skipped malformed records.
|
||||
- `AdaptiveRefreshCore` owns the production decision table. `ReplayPolicy.swift`,
|
||||
`BaselinePolicies.swift`, and `CandidatePolicies.swift` provide replay adapters, fixed/manual
|
||||
baselines, and the replay-only activity candidate.
|
||||
- `ReplayEngine.swift` and `ReplayMetrics.swift` calculate simulated refresh cadence, menu-open
|
||||
staleness, interaction advances, and constrained-state compliance.
|
||||
- `ReplayTraceSegmentation.swift` excludes legacy deadline-overrun gaps with an explicit heuristic
|
||||
and reports the excluded duration.
|
||||
- `RecordedScheduleAudit.swift` audits recorded timer-advance events independently from the replay
|
||||
clock.
|
||||
- `Sources/AdaptiveReplayCLI` formats table or JSON reports.
|
||||
|
||||
`interactionAdvanceCount` is counterfactual. Replay assumes a zero-duration refresh, while the
|
||||
live app waits for provider work and may already have a refresh in flight. Recorded schedule events
|
||||
therefore have a separate audit instead of a direct count comparison.
|
||||
|
||||
The legacy gap heuristic cannot distinguish sleep or reboot from a long refresh or event-loop
|
||||
stall. Reports expose the segment count, grace interval, and excluded time rather than assigning a
|
||||
cause.
|
||||
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
public struct RecordedScheduleAudit: Sendable, Equatable {
|
||||
public let recordedAdvanceCount: Int
|
||||
public let evaluatedCount: Int
|
||||
public let acceptedEvaluationCount: Int
|
||||
public let rejectedEvaluationCount: Int
|
||||
public let payloadMismatchCount: Int
|
||||
public let decisionMismatchCount: Int
|
||||
public let menuLinkMismatchCount: Int
|
||||
public let ambiguousComparisonCount: Int
|
||||
|
||||
public var isValid: Bool {
|
||||
self.payloadMismatchCount == 0
|
||||
&& self.decisionMismatchCount == 0
|
||||
&& self.menuLinkMismatchCount == 0
|
||||
&& self.ambiguousComparisonCount == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Audits the live schedule records without equating them to ReplayEngine's counterfactual clock.
|
||||
public enum RecordedScheduleAuditor {
|
||||
public static func audit(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
timestampTolerance: TimeInterval = 1) -> RecordedScheduleAudit
|
||||
{
|
||||
let sorted = records.sorted { $0.timestamp < $1.timestamp }
|
||||
let menuTimestamps = sorted.filter { $0.kind == .menuOpen }.map(\.timestamp)
|
||||
let advances = sorted.filter { $0.kind == .timerAdvanced }
|
||||
let evaluations = sorted.filter { $0.kind == .timerAdvanceEvaluated }
|
||||
|
||||
var payloadMismatchCount = advances.count(where: { !Self.payloadIsValid($0) })
|
||||
let evaluationOutcomes = evaluations.map(Self.evaluationOutcome)
|
||||
let decisionMismatchCount = evaluationOutcomes.count(where: { $0 == .mismatch })
|
||||
let ambiguousComparisonCount = evaluationOutcomes.count(where: { $0 == .ambiguous })
|
||||
// Every evaluation is caused by one menu open. Before evaluation records existed, an
|
||||
// accepted advance was the only causal record, so retain those legacy advances as linkage
|
||||
// events. Modern accepted advances are reconciled against evaluations below instead of
|
||||
// consuming the same menu open twice.
|
||||
let legacyAdvances = evaluations.first.map { firstEvaluation in
|
||||
advances.filter { $0.timestamp < firstEvaluation.timestamp }
|
||||
} ?? advances
|
||||
let menuLinkMismatchCount = Self.unmatchedEventCount(
|
||||
evaluations + legacyAdvances,
|
||||
menuTimestamps: menuTimestamps,
|
||||
timestampTolerance: timestampTolerance)
|
||||
|
||||
if let firstEvaluationAt = evaluations.first?.timestamp {
|
||||
let accepted = evaluations.filter { $0.timerAdvanceAccepted == true }
|
||||
let auditableAdvances = advances.filter { $0.timestamp >= firstEvaluationAt }
|
||||
payloadMismatchCount += Self.scheduleMultiplicityDifference(accepted, auditableAdvances)
|
||||
}
|
||||
|
||||
return RecordedScheduleAudit(
|
||||
recordedAdvanceCount: advances.count,
|
||||
evaluatedCount: evaluations.count,
|
||||
acceptedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == true }),
|
||||
rejectedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == false }),
|
||||
payloadMismatchCount: payloadMismatchCount,
|
||||
decisionMismatchCount: decisionMismatchCount,
|
||||
menuLinkMismatchCount: menuLinkMismatchCount,
|
||||
ambiguousComparisonCount: ambiguousComparisonCount)
|
||||
}
|
||||
|
||||
private static func payloadIsValid(_ record: AdaptiveRefreshTraceRecord) -> Bool {
|
||||
guard let candidate = record.candidateScheduledAt,
|
||||
let delay = record.delaySeconds,
|
||||
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
|
||||
else { return false }
|
||||
// Whole-second legacy timestamps can collapse a sub-second accepted lead to equality.
|
||||
return record.previousScheduledAt.map { candidate <= $0 } ?? true
|
||||
}
|
||||
|
||||
private enum EvaluationOutcome: Equatable {
|
||||
case valid
|
||||
case mismatch
|
||||
case ambiguous
|
||||
}
|
||||
|
||||
private static func evaluationOutcome(_ record: AdaptiveRefreshTraceRecord) -> EvaluationOutcome {
|
||||
guard let accepted = record.timerAdvanceAccepted,
|
||||
let candidate = record.candidateScheduledAt,
|
||||
let delay = record.delaySeconds,
|
||||
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
|
||||
else { return .mismatch }
|
||||
guard let previous = record.previousScheduledAt else { return accepted ? .valid : .mismatch }
|
||||
if candidate != previous {
|
||||
return accepted == (candidate < previous) ? .valid : .mismatch
|
||||
}
|
||||
guard let lead = record.scheduleLeadSeconds else { return .ambiguous }
|
||||
return accepted == (lead > 0) ? .valid : .mismatch
|
||||
}
|
||||
|
||||
private struct ScheduleKey: Hashable {
|
||||
let timestamp: Date
|
||||
let previousScheduledAt: Date?
|
||||
let candidateScheduledAt: Date?
|
||||
let reason: String?
|
||||
let delaySeconds: TimeInterval?
|
||||
}
|
||||
|
||||
private static func scheduleMultiplicityDifference(
|
||||
_ lhs: [AdaptiveRefreshTraceRecord],
|
||||
_ rhs: [AdaptiveRefreshTraceRecord]) -> Int
|
||||
{
|
||||
func counts(_ records: [AdaptiveRefreshTraceRecord]) -> [ScheduleKey: Int] {
|
||||
Dictionary(grouping: records, by: scheduleKey).mapValues(\.count)
|
||||
}
|
||||
let lhsCounts = counts(lhs)
|
||||
let rhsCounts = counts(rhs)
|
||||
return Set(lhsCounts.keys).union(rhsCounts.keys).reduce(0) { difference, key in
|
||||
difference + abs(lhsCounts[key, default: 0] - rhsCounts[key, default: 0])
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum one-to-one matching for sorted points with a symmetric tolerance window. Extra
|
||||
/// menu opens are valid because fixed/manual modes do not emit schedule evaluations; only an
|
||||
/// event without its own causal menu open is a mismatch.
|
||||
private static func unmatchedEventCount(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
menuTimestamps: [Date],
|
||||
timestampTolerance: TimeInterval) -> Int
|
||||
{
|
||||
let eventTimestamps = records.map(\.timestamp).sorted()
|
||||
var eventIndex = 0
|
||||
var menuIndex = 0
|
||||
var unmatched = 0
|
||||
|
||||
while eventIndex < eventTimestamps.count, menuIndex < menuTimestamps.count {
|
||||
let eventTimestamp = eventTimestamps[eventIndex]
|
||||
let menuTimestamp = menuTimestamps[menuIndex]
|
||||
if menuTimestamp < eventTimestamp.addingTimeInterval(-timestampTolerance) {
|
||||
menuIndex += 1
|
||||
} else if menuTimestamp > eventTimestamp.addingTimeInterval(timestampTolerance) {
|
||||
unmatched += 1
|
||||
eventIndex += 1
|
||||
} else {
|
||||
eventIndex += 1
|
||||
menuIndex += 1
|
||||
}
|
||||
}
|
||||
return unmatched + eventTimestamps.count - eventIndex
|
||||
}
|
||||
|
||||
private static func scheduleKey(_ record: AdaptiveRefreshTraceRecord) -> ScheduleKey {
|
||||
ScheduleKey(
|
||||
timestamp: record.timestamp,
|
||||
previousScheduledAt: record.previousScheduledAt,
|
||||
candidateScheduledAt: record.candidateScheduledAt,
|
||||
reason: record.reason,
|
||||
delaySeconds: record.delaySeconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import Foundation
|
||||
|
||||
/// Simulates the live timer loop (`decide` → sleep → refresh → `decide` → ...) over a trace's
|
||||
/// observed span for a given `ReplayPolicy`, pure and deterministic: the same trace and policy
|
||||
/// always produce the same `ReplayMetrics`, since every input the policy sees comes from the
|
||||
/// trace, never from a live clock.
|
||||
///
|
||||
/// Ground truth vs. reconstructed signal: `menuOpen` events are ground truth — a menu either
|
||||
/// opened at a timestamp or it didn't, independent of any policy. `lowPowerModeEnabled` and
|
||||
/// `thermalState`, by contrast, are only *sampled* at the timestamps the trace's original
|
||||
/// `decision` events happened to occur at (whatever policy produced the trace). When a candidate
|
||||
/// policy's own tick times fall between those samples, the engine holds the most recent known
|
||||
/// value (step function). This is the phase-1 approximation: without a continuous power/thermal
|
||||
/// signal in the trace, "most recent sample" is the best available reconstruction. Before the
|
||||
/// first known sample, the earliest available sample is used (hold-first).
|
||||
///
|
||||
/// Interaction advances: this is a *counterfactual* replay, not a literal replay of whatever the
|
||||
/// recording policy happened to do — each candidate policy gets its own tick schedule computed
|
||||
/// fresh from `policy.decide(_:)`. To reproduce `UsageStore.noteMenuOpened(at:)`'s "pull the timer
|
||||
/// forward" behavior (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`) for
|
||||
/// *any* candidate policy, every `menuOpen` event that falls inside a policy's current tick window
|
||||
/// is independently re-evaluated: if `policy.advancesOnInteraction` and the decision computed as of
|
||||
/// that menu open would land earlier than the already-scheduled next tick, the schedule advances to
|
||||
/// that earlier time, exactly like `startTimer(preservingResetBoundaryRefresh: true)` replacing a
|
||||
/// pending sleep with a shorter one. Recorded `timerAdvanced` events are audited separately: their
|
||||
/// count is not expected to equal
|
||||
/// this counterfactual schedule because live refresh work has non-zero duration and can coalesce.
|
||||
public enum ReplayEngine {
|
||||
/// Safety valve against a pathological policy (e.g. a zero-or-negative delay bug) turning a
|
||||
/// long trace into an unbounded loop.
|
||||
private static let maxIterations = 2_000_000
|
||||
|
||||
/// The trace-derived, replay-invariant inputs the simulation loop reads on every tick:
|
||||
/// menu-open ground truth plus the sampled power/thermal signal, both precomputed and sorted
|
||||
/// once per `run` so the per-tick lookups stay O(log n).
|
||||
private struct TraceSignals {
|
||||
let menuOpenTimestamps: [Date]
|
||||
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)]
|
||||
let signalTimestamps: [Date]
|
||||
let activitySamples: [ActivityObservation]
|
||||
let activityTimestamps: [Date]
|
||||
}
|
||||
|
||||
private struct ActivityObservation {
|
||||
let timestamp: Date
|
||||
let lastCodingActivityAt: Date?
|
||||
}
|
||||
|
||||
public static func run(trace: [AdaptiveRefreshTraceRecord], policy: some ReplayPolicy) -> ReplayMetrics {
|
||||
self.runDetailed(trace: trace, policy: policy).metrics
|
||||
}
|
||||
|
||||
static func runDetailed(
|
||||
trace: [AdaptiveRefreshTraceRecord],
|
||||
policy: some ReplayPolicy,
|
||||
stalenessStartAt: Date? = nil) -> ReplayRun
|
||||
{
|
||||
guard let start = trace.map(\.timestamp).min(), let end = trace.map(\.timestamp).max() else {
|
||||
return ReplayRun(
|
||||
metrics: ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: 0,
|
||||
totalRefreshCount: 0,
|
||||
refreshCountPer24h: 0,
|
||||
stalenessAtMenuOpen: nil,
|
||||
constrainedCompliance: ConstrainedCompliance(constrainedDecisionCount: 0, violationCount: 0)),
|
||||
stalenessSamples: [])
|
||||
}
|
||||
|
||||
let menuOpenTimestamps = trace
|
||||
.filter { $0.kind == .menuOpen }
|
||||
.map(\.timestamp)
|
||||
.sorted()
|
||||
|
||||
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)] = trace
|
||||
.filter { $0.kind == .decision }
|
||||
.compactMap { record in
|
||||
guard let lowPower = record.lowPowerModeEnabled, let thermal = record.thermalState else {
|
||||
return nil
|
||||
}
|
||||
return (timestamp: record.timestamp, lowPower: lowPower, thermal: thermal)
|
||||
}
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
let activitySamples = trace
|
||||
.filter { $0.kind == .decision }
|
||||
.map { record in
|
||||
let activityDates = [record.codexActivitySeconds, record.claudeActivitySeconds]
|
||||
.compactMap(\.self)
|
||||
.map { record.timestamp.addingTimeInterval(-max(0, $0)) }
|
||||
return ActivityObservation(
|
||||
timestamp: record.timestamp,
|
||||
lastCodingActivityAt: activityDates.max())
|
||||
}
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
let signals = TraceSignals(
|
||||
menuOpenTimestamps: menuOpenTimestamps,
|
||||
signalSamples: signalSamples,
|
||||
signalTimestamps: signalSamples.map(\.timestamp),
|
||||
activitySamples: activitySamples,
|
||||
activityTimestamps: activitySamples.map(\.timestamp))
|
||||
|
||||
var cursor = start
|
||||
var refreshTimestamps: [Date] = []
|
||||
var constrainedDecisionCount = 0
|
||||
var violationCount = 0
|
||||
var interactionAdvanceCount = 0
|
||||
var codingActiveDecisionCount = 0
|
||||
var codingActiveDelayViolationCount = 0
|
||||
var iterations = 0
|
||||
// Monotonic pointer into `menuOpenTimestamps`: the scan below considers each menu open for
|
||||
// an advance at most once, in the single tick window (cursor, next] it falls into.
|
||||
var menuOpenScanIndex = 0
|
||||
|
||||
while cursor <= end, iterations < self.maxIterations {
|
||||
iterations += 1
|
||||
let (lowPower, thermal) = self.signal(
|
||||
signals.signalSamples,
|
||||
timestamps: signals.signalTimestamps,
|
||||
at: cursor)
|
||||
let input = ReplayPolicyInput(
|
||||
now: cursor,
|
||||
lastMenuOpenAt: self.lastValue(menuOpenTimestamps, atOrBefore: cursor),
|
||||
lastCodingActivityAt: self.lastActivity(
|
||||
signals.activitySamples,
|
||||
timestamps: signals.activityTimestamps,
|
||||
at: cursor),
|
||||
lowPowerModeEnabled: lowPower,
|
||||
thermalState: thermal)
|
||||
let decision = policy.decide(input)
|
||||
|
||||
if input.isConstrained {
|
||||
constrainedDecisionCount += 1
|
||||
if let delay = decision.delaySeconds, delay < 1800 {
|
||||
violationCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if !input.isConstrained,
|
||||
let activityAge = input.codingActivityAgeSeconds,
|
||||
activityAge < 5 * 60
|
||||
{
|
||||
codingActiveDecisionCount += 1
|
||||
if decision.delaySeconds.map({ $0 <= 0 || $0 > 5 * 60 }) ?? true {
|
||||
codingActiveDelayViolationCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard let delay = decision.delaySeconds, delay > 0 else { break }
|
||||
var next = cursor.addingTimeInterval(delay)
|
||||
|
||||
if policy.advancesOnInteraction {
|
||||
let advanced = self.applyInteractionAdvances(
|
||||
policy: policy,
|
||||
signals: signals,
|
||||
scanIndex: &menuOpenScanIndex,
|
||||
windowStart: cursor,
|
||||
scheduledAt: next)
|
||||
next = advanced.scheduledAt
|
||||
interactionAdvanceCount += advanced.advanceCount
|
||||
}
|
||||
|
||||
guard next <= end else { break }
|
||||
refreshTimestamps.append(next)
|
||||
cursor = next
|
||||
}
|
||||
|
||||
let span = end.timeIntervalSince(start)
|
||||
let refreshCountPer24h = span > 0 ? Double(refreshTimestamps.count) * 86400 / span : 0
|
||||
|
||||
let stalenessMenuTimestamps = stalenessStartAt.map { start in
|
||||
menuOpenTimestamps.filter { $0 >= start }
|
||||
} ?? menuOpenTimestamps
|
||||
let stalenessSamples = stalenessMenuTimestamps.isEmpty ? [] : self.stalenessSamples(
|
||||
menuOpenTimestamps: stalenessMenuTimestamps,
|
||||
refreshTimestamps: refreshTimestamps,
|
||||
initialFreshAt: stalenessStartAt ?? start)
|
||||
|
||||
return ReplayRun(
|
||||
metrics: ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: span,
|
||||
totalRefreshCount: refreshTimestamps.count,
|
||||
refreshCountPer24h: refreshCountPer24h,
|
||||
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
|
||||
constrainedCompliance: ConstrainedCompliance(
|
||||
constrainedDecisionCount: constrainedDecisionCount,
|
||||
violationCount: violationCount),
|
||||
interactionAdvanceCount: interactionAdvanceCount,
|
||||
codingActiveDecisionCount: codingActiveDecisionCount,
|
||||
codingActiveDelayViolationCount: codingActiveDelayViolationCount),
|
||||
stalenessSamples: stalenessSamples)
|
||||
}
|
||||
|
||||
/// Re-evaluates every not-yet-scanned menu open that falls in `(windowStart, scheduledAt]`
|
||||
/// against `policy`, mirroring `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`:
|
||||
/// a menu open at time `T` computes `policy.decide(now: T, lastMenuOpenAt: T, ...)` (age zero,
|
||||
/// exactly as `noteMenuOpened(at:)` does with `self.lastMenuOpenAt = date` already applied), and
|
||||
/// if the resulting candidate (`T + delay`) lands earlier than the currently scheduled refresh,
|
||||
/// the schedule advances to that candidate. Later menu opens in the same window are then
|
||||
/// compared against the *advanced* schedule, same as a real second interaction tightening an
|
||||
/// already-shortened sleep. Returns the (possibly advanced) scheduled time plus how many
|
||||
/// advances were taken in this window.
|
||||
private static func applyInteractionAdvances(
|
||||
policy: some ReplayPolicy,
|
||||
signals: TraceSignals,
|
||||
scanIndex: inout Int,
|
||||
windowStart: Date,
|
||||
scheduledAt: Date) -> (scheduledAt: Date, advanceCount: Int)
|
||||
{
|
||||
var next = scheduledAt
|
||||
var advanceCount = 0
|
||||
while scanIndex < signals.menuOpenTimestamps.count {
|
||||
let menuOpenAt = signals.menuOpenTimestamps[scanIndex]
|
||||
guard menuOpenAt > windowStart else {
|
||||
scanIndex += 1
|
||||
continue
|
||||
}
|
||||
guard menuOpenAt <= next else { break }
|
||||
|
||||
let (lowPower, thermal) = self.signal(
|
||||
signals.signalSamples,
|
||||
timestamps: signals.signalTimestamps,
|
||||
at: menuOpenAt)
|
||||
let advanceDecision = policy.decide(ReplayPolicyInput(
|
||||
now: menuOpenAt,
|
||||
lastMenuOpenAt: menuOpenAt,
|
||||
lastCodingActivityAt: self.lastActivity(
|
||||
signals.activitySamples,
|
||||
timestamps: signals.activityTimestamps,
|
||||
at: menuOpenAt),
|
||||
lowPowerModeEnabled: lowPower,
|
||||
thermalState: thermal))
|
||||
scanIndex += 1
|
||||
|
||||
guard let advanceDelay = advanceDecision.delaySeconds, advanceDelay > 0 else { continue }
|
||||
let candidate = menuOpenAt.addingTimeInterval(advanceDelay)
|
||||
if candidate < next {
|
||||
next = candidate
|
||||
advanceCount += 1
|
||||
}
|
||||
}
|
||||
return (next, advanceCount)
|
||||
}
|
||||
|
||||
private static func stalenessSamples(
|
||||
menuOpenTimestamps: [Date],
|
||||
refreshTimestamps: [Date],
|
||||
initialFreshAt: Date) -> [Double]
|
||||
{
|
||||
menuOpenTimestamps.map { menuOpenAt in
|
||||
let simulatedRefresh = self.lastValue(refreshTimestamps, atOrBefore: menuOpenAt)
|
||||
let freshestAt = simulatedRefresh.map { max($0, initialFreshAt) } ?? initialFreshAt
|
||||
return menuOpenAt.timeIntervalSince(freshestAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func lastActivity(
|
||||
_ samples: [ActivityObservation],
|
||||
timestamps: [Date],
|
||||
at time: Date) -> Date?
|
||||
{
|
||||
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
|
||||
return samples[index].lastCodingActivityAt
|
||||
}
|
||||
|
||||
/// Binds the most recent power/thermal sample at or before `time` (hold-last), falling back
|
||||
/// to the earliest known sample when `time` precedes every sample (hold-first), and to
|
||||
/// nominal/not-low-power when no samples exist at all.
|
||||
private static func signal(
|
||||
_ samples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)],
|
||||
timestamps: [Date],
|
||||
at time: Date) -> (Bool, ReplayThermalState)
|
||||
{
|
||||
guard !samples.isEmpty else { return (false, .nominal) }
|
||||
if let index = self.lastIndex(timestamps, atOrBefore: time) {
|
||||
return (samples[index].lowPower, samples[index].thermal)
|
||||
}
|
||||
return (samples[0].lowPower, samples[0].thermal)
|
||||
}
|
||||
|
||||
private static func lastValue(_ timestamps: [Date], atOrBefore time: Date) -> Date? {
|
||||
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
|
||||
return timestamps[index]
|
||||
}
|
||||
|
||||
/// Binary search for the last index whose timestamp is `<= time`, assuming `timestamps` is
|
||||
/// sorted ascending. O(log n) so a long trace (thousands of decisions) stays fast to replay.
|
||||
private static func lastIndex(_ timestamps: [Date], atOrBefore time: Date) -> Int? {
|
||||
var low = 0
|
||||
var high = timestamps.count - 1
|
||||
var result: Int?
|
||||
while low <= high {
|
||||
let mid = (low + high) / 2
|
||||
if timestamps[mid] <= time {
|
||||
result = mid
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// Mean/median/p95 of staleness (seconds since the last simulated refresh) observed at each
|
||||
/// historical menu-open event. `p95` uses nearest-rank: samples are sorted ascending and index
|
||||
/// `ceil(0.95 * n) - 1` (clamped to the last index) is reported — the same convention most
|
||||
/// dashboards use for small-to-medium sample counts, and simple enough to hand-verify in tests.
|
||||
public struct StalenessStats: Sendable, Equatable {
|
||||
public let mean: Double
|
||||
public let median: Double
|
||||
public let p95: Double
|
||||
public let sampleCount: Int
|
||||
|
||||
public init(mean: Double, median: Double, p95: Double, sampleCount: Int) {
|
||||
self.mean = mean
|
||||
self.median = median
|
||||
self.p95 = p95
|
||||
self.sampleCount = sampleCount
|
||||
}
|
||||
|
||||
init?(samples: [Double]) {
|
||||
guard !samples.isEmpty else { return nil }
|
||||
let sorted = samples.sorted()
|
||||
self.init(
|
||||
mean: sorted.reduce(0, +) / Double(sorted.count),
|
||||
median: Self.percentile(sorted, fraction: 0.5),
|
||||
p95: Self.percentile(sorted, fraction: 0.95),
|
||||
sampleCount: sorted.count)
|
||||
}
|
||||
|
||||
private static func percentile(_ sorted: [Double], fraction: Double) -> Double {
|
||||
let rank = Int((fraction * Double(sorted.count)).rounded(.up))
|
||||
return sorted[max(0, min(sorted.count - 1, rank - 1))]
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a policy honored the "never refresh faster than 30 minutes while constrained (low
|
||||
/// power or serious/critical thermal)" rule at every simulated decision point where the input was
|
||||
/// constrained.
|
||||
public struct ConstrainedCompliance: Sendable, Equatable {
|
||||
public let constrainedDecisionCount: Int
|
||||
public let violationCount: Int
|
||||
|
||||
public init(constrainedDecisionCount: Int, violationCount: Int) {
|
||||
self.constrainedDecisionCount = constrainedDecisionCount
|
||||
self.violationCount = violationCount
|
||||
}
|
||||
|
||||
public var isCompliant: Bool {
|
||||
self.violationCount == 0
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplayMetrics: Sendable, Equatable {
|
||||
public let policyName: String
|
||||
public let simulatedSpanSeconds: TimeInterval
|
||||
public let totalRefreshCount: Int
|
||||
public let refreshCountPer24h: Double
|
||||
public let stalenessAtMenuOpen: StalenessStats?
|
||||
public let constrainedCompliance: ConstrainedCompliance
|
||||
/// How many of `totalRefreshCount` were pulled forward by a menu-open interaction rather than
|
||||
/// firing on the policy's own previously scheduled cadence — i.e. how many times
|
||||
/// `ReplayEngine.run` took the `advancesOnInteraction` branch for this policy. Always `0` for
|
||||
/// policies that report `advancesOnInteraction == false` (see `ReplayPolicy`).
|
||||
public let interactionAdvanceCount: Int
|
||||
/// Unconstrained replayed decisions with a known transcript-write observation under five minutes old.
|
||||
public let codingActiveDecisionCount: Int
|
||||
/// Unconstrained active decisions whose selected delay exceeded the five-minute acceptance cap.
|
||||
public let codingActiveDelayViolationCount: Int
|
||||
/// Number of independently simulated awake/run segments contributing to these metrics.
|
||||
public let segmentCount: Int
|
||||
/// Wall-clock time excluded after an expected timer deadline because the app was unobserved.
|
||||
public let excludedGapSeconds: TimeInterval
|
||||
/// Menu opens before a segment's first recorded refresh, excluded equally for every policy.
|
||||
public let boundaryCensoredMenuOpenCount: Int
|
||||
|
||||
public init(
|
||||
policyName: String,
|
||||
simulatedSpanSeconds: TimeInterval,
|
||||
totalRefreshCount: Int,
|
||||
refreshCountPer24h: Double,
|
||||
stalenessAtMenuOpen: StalenessStats?,
|
||||
constrainedCompliance: ConstrainedCompliance,
|
||||
interactionAdvanceCount: Int = 0,
|
||||
codingActiveDecisionCount: Int = 0,
|
||||
codingActiveDelayViolationCount: Int = 0,
|
||||
segmentCount: Int = 1,
|
||||
excludedGapSeconds: TimeInterval = 0,
|
||||
boundaryCensoredMenuOpenCount: Int = 0)
|
||||
{
|
||||
self.policyName = policyName
|
||||
self.simulatedSpanSeconds = simulatedSpanSeconds
|
||||
self.totalRefreshCount = totalRefreshCount
|
||||
self.refreshCountPer24h = refreshCountPer24h
|
||||
self.stalenessAtMenuOpen = stalenessAtMenuOpen
|
||||
self.constrainedCompliance = constrainedCompliance
|
||||
self.interactionAdvanceCount = interactionAdvanceCount
|
||||
self.codingActiveDecisionCount = codingActiveDecisionCount
|
||||
self.codingActiveDelayViolationCount = codingActiveDelayViolationCount
|
||||
self.segmentCount = segmentCount
|
||||
self.excludedGapSeconds = excludedGapSeconds
|
||||
self.boundaryCensoredMenuOpenCount = boundaryCensoredMenuOpenCount
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplayRun: Sendable {
|
||||
let metrics: ReplayMetrics
|
||||
let stalenessSamples: [Double]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
// Replay harness for the adaptive refresh policy shipped in the `CodexBar` app target. The app
|
||||
// and replay adapter both call `AdaptiveRefreshPolicyCore`; these types only normalize replay
|
||||
// inputs and report replay-friendly output.
|
||||
|
||||
/// Coarse thermal-pressure signal matching the two `ProcessInfo.ThermalState` cases the policy
|
||||
/// distinguishes (`.serious`/`.critical` vs everything else), expressed independently so this
|
||||
/// library never needs Darwin-only APIs and can build on any platform.
|
||||
public enum ReplayThermalState: String, Sendable, Codable, CaseIterable {
|
||||
case nominal
|
||||
case fair
|
||||
case serious
|
||||
case critical
|
||||
|
||||
public var isConstrained: Bool {
|
||||
self == .serious || self == .critical
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs a refresh-timing policy needs to decide how long to wait before the next refresh.
|
||||
/// Replay-specific policy input. Platform-independent fields map into the shared policy core.
|
||||
public struct ReplayPolicyInput: Sendable, Equatable {
|
||||
public let now: Date
|
||||
public let lastMenuOpenAt: Date?
|
||||
/// Most recent transcript write reconstructed from the latest activity observation available
|
||||
/// at or before `now`. This is nil when that observation could not see either CLI.
|
||||
public let lastCodingActivityAt: Date?
|
||||
public let lowPowerModeEnabled: Bool
|
||||
public let thermalState: ReplayThermalState
|
||||
|
||||
public init(
|
||||
now: Date,
|
||||
lastMenuOpenAt: Date?,
|
||||
lastCodingActivityAt: Date? = nil,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalState: ReplayThermalState)
|
||||
{
|
||||
self.now = now
|
||||
self.lastMenuOpenAt = lastMenuOpenAt
|
||||
self.lastCodingActivityAt = lastCodingActivityAt
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
self.thermalState = thermalState
|
||||
}
|
||||
|
||||
/// Whether this input represents a power/thermal-constrained moment, independent of which
|
||||
/// policy is deciding. Used by the replay engine to score constrained-tier compliance without
|
||||
/// depending on any single policy's own notion of "constrained".
|
||||
public var isConstrained: Bool {
|
||||
self.lowPowerModeEnabled || self.thermalState.isConstrained
|
||||
}
|
||||
|
||||
public var codingActivityAgeSeconds: TimeInterval? {
|
||||
self.lastCodingActivityAt.map { max(0, self.now.timeIntervalSince($0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A policy's decision: how long to wait, and a short human-readable reason code for reporting.
|
||||
/// `delaySeconds == nil` means "never schedule another refresh" — the degenerate floor used by
|
||||
/// `ManualPolicy`.
|
||||
public struct ReplayPolicyDecision: Sendable, Equatable {
|
||||
public let delaySeconds: TimeInterval?
|
||||
public let reason: String
|
||||
|
||||
public init(delaySeconds: TimeInterval?, reason: String) {
|
||||
self.delaySeconds = delaySeconds
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/// A pure, deterministic function from `ReplayPolicyInput` to `ReplayPolicyDecision`.
|
||||
public protocol ReplayPolicy: Sendable {
|
||||
var name: String { get }
|
||||
|
||||
/// Whether opening the menu can pull this policy's next refresh forward, mirroring
|
||||
/// `UsageStore.noteMenuOpened(at:)`'s guard on `settings.refreshFrequency == .adaptive`: in the
|
||||
/// real app, only adaptive mode ever advances the timer from an interaction — fixed-cadence and
|
||||
/// manual modes just record `lastMenuOpenAt` and let the existing schedule run. Defaults to
|
||||
/// `false` so baseline policies (`FixedIntervalPolicy`, `ManualPolicy`) need no override; only
|
||||
/// policies that actually model the adaptive table set this to `true`.
|
||||
var advancesOnInteraction: Bool { get }
|
||||
|
||||
func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision
|
||||
}
|
||||
|
||||
extension ReplayPolicy {
|
||||
public var advancesOnInteraction: Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
public struct ReplayTraceSegment: Sendable, Equatable {
|
||||
public let records: [AdaptiveRefreshTraceRecord]
|
||||
public let start: Date
|
||||
public let end: Date
|
||||
|
||||
var replayRecords: [AdaptiveRefreshTraceRecord] {
|
||||
guard self.records.last?.timestamp != self.end else { return self.records }
|
||||
return self.records + [.refreshCompleted(timestamp: self.end)]
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplaySegmentationReport: Sendable, Equatable {
|
||||
public let segments: [ReplayTraceSegment]
|
||||
public let excludedGapSeconds: TimeInterval
|
||||
public let breakCount: Int
|
||||
public let graceSeconds: TimeInterval
|
||||
|
||||
public var includedSpanSeconds: TimeInterval {
|
||||
self.segments.reduce(0) { $0 + max(0, $1.end.timeIntervalSince($1.start)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits legacy traces only when observation resumes well after the last timer deadline. The
|
||||
/// normal scheduled wait remains inside the preceding segment; only overdue wall time is excluded.
|
||||
public enum ReplayTraceSegmenter {
|
||||
public static let defaultGraceSeconds: TimeInterval = 5 * 60
|
||||
|
||||
public static func automatic(
|
||||
_ records: [AdaptiveRefreshTraceRecord],
|
||||
graceSeconds: TimeInterval = Self.defaultGraceSeconds) -> ReplaySegmentationReport
|
||||
{
|
||||
let sorted = records.sorted { $0.timestamp < $1.timestamp }
|
||||
guard let first = sorted.first else {
|
||||
return ReplaySegmentationReport(
|
||||
segments: [], excludedGapSeconds: 0, breakCount: 0, graceSeconds: graceSeconds)
|
||||
}
|
||||
|
||||
var segments: [ReplayTraceSegment] = []
|
||||
var currentRecords: [AdaptiveRefreshTraceRecord] = []
|
||||
var currentStart = first.timestamp
|
||||
var expectedDeadline: Date?
|
||||
var excludedGapSeconds: TimeInterval = 0
|
||||
|
||||
for record in sorted {
|
||||
if let deadline = expectedDeadline,
|
||||
record.timestamp.timeIntervalSince(deadline) > graceSeconds,
|
||||
!currentRecords.isEmpty
|
||||
{
|
||||
let end = max(currentRecords.last!.timestamp, deadline)
|
||||
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: end))
|
||||
excludedGapSeconds += max(0, record.timestamp.timeIntervalSince(end))
|
||||
currentRecords = []
|
||||
currentStart = record.timestamp
|
||||
expectedDeadline = nil
|
||||
}
|
||||
|
||||
currentRecords.append(record)
|
||||
if record.kind == .decision, let delay = record.delaySeconds, delay > 0 {
|
||||
expectedDeadline = record.timestamp.addingTimeInterval(delay)
|
||||
} else if record.kind == .timerAdvanced, let candidate = record.candidateScheduledAt {
|
||||
expectedDeadline = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if let last = currentRecords.last {
|
||||
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: last.timestamp))
|
||||
}
|
||||
return ReplaySegmentationReport(
|
||||
segments: segments,
|
||||
excludedGapSeconds: excludedGapSeconds,
|
||||
breakCount: max(0, segments.count - 1),
|
||||
graceSeconds: graceSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension ReplayEngine {
|
||||
public static func runSegmented(
|
||||
trace: [AdaptiveRefreshTraceRecord],
|
||||
policy: some ReplayPolicy,
|
||||
graceSeconds: TimeInterval = ReplayTraceSegmenter.defaultGraceSeconds) -> ReplayMetrics
|
||||
{
|
||||
let report = ReplayTraceSegmenter.automatic(trace, graceSeconds: graceSeconds)
|
||||
let stalenessStarts = report.segments.map { segment in
|
||||
segment.records.first(where: { $0.kind == .refreshCompleted })?.timestamp
|
||||
}
|
||||
let runs = zip(report.segments, stalenessStarts).map { segment, stalenessStart in
|
||||
self.runDetailed(
|
||||
trace: segment.replayRecords,
|
||||
policy: policy,
|
||||
stalenessStartAt: stalenessStart ?? .distantFuture)
|
||||
}
|
||||
let boundaryCensoredMenuOpenCount = zip(report.segments, stalenessStarts).reduce(0) { partial, pair in
|
||||
let (segment, stalenessStart) = pair
|
||||
return partial + segment.records.count(where: { record in
|
||||
record.kind == .menuOpen && (stalenessStart.map { record.timestamp < $0 } ?? true)
|
||||
})
|
||||
}
|
||||
let span = report.includedSpanSeconds
|
||||
let refreshCount = runs.reduce(0) { $0 + $1.metrics.totalRefreshCount }
|
||||
let stalenessSamples = runs.flatMap(\.stalenessSamples)
|
||||
return ReplayMetrics(
|
||||
policyName: policy.name,
|
||||
simulatedSpanSeconds: span,
|
||||
totalRefreshCount: refreshCount,
|
||||
refreshCountPer24h: span > 0 ? Double(refreshCount) * 86400 / span : 0,
|
||||
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
|
||||
constrainedCompliance: ConstrainedCompliance(
|
||||
constrainedDecisionCount: runs.reduce(0) {
|
||||
$0 + $1.metrics.constrainedCompliance.constrainedDecisionCount
|
||||
},
|
||||
violationCount: runs.reduce(0) { $0 + $1.metrics.constrainedCompliance.violationCount }),
|
||||
interactionAdvanceCount: runs.reduce(0) { $0 + $1.metrics.interactionAdvanceCount },
|
||||
codingActiveDecisionCount: runs.reduce(0) { $0 + $1.metrics.codingActiveDecisionCount },
|
||||
codingActiveDelayViolationCount: runs.reduce(0) {
|
||||
$0 + $1.metrics.codingActiveDelayViolationCount
|
||||
},
|
||||
segmentCount: report.segments.count,
|
||||
excludedGapSeconds: report.excludedGapSeconds,
|
||||
boundaryCensoredMenuOpenCount: boundaryCensoredMenuOpenCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module CSQLite3 [system] {
|
||||
header "shim.h"
|
||||
link "sqlite3"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#include <sqlite3.h>
|
||||
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
func showAbout() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–"
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
|
||||
let versionString = build.isEmpty ? version : "\(version) (\(build))"
|
||||
let buildTimestamp = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String
|
||||
let gitCommit = Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String
|
||||
|
||||
let separator = NSAttributedString(string: " · ", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
])
|
||||
|
||||
func makeLink(_ title: String, urlString: String) -> NSAttributedString {
|
||||
NSAttributedString(string: title, attributes: [
|
||||
.link: URL(string: urlString) as Any,
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
])
|
||||
}
|
||||
|
||||
let credits = NSMutableAttributedString(string: "Peter Steinberger — MIT License\n")
|
||||
credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar"))
|
||||
credits.append(separator)
|
||||
credits.append(makeLink("Website", urlString: "https://codexbar.app"))
|
||||
credits.append(separator)
|
||||
credits.append(makeLink("Twitter", urlString: "https://twitter.com/steipete"))
|
||||
credits.append(separator)
|
||||
credits.append(makeLink("Email", urlString: "mailto:peter@steipete.me"))
|
||||
if let buildTimestamp, let formatted = formattedBuildTimestamp(buildTimestamp) {
|
||||
var builtLine = "Built \(formatted)"
|
||||
if let gitCommit, !gitCommit.isEmpty, gitCommit != "unknown" {
|
||||
builtLine += " (\(gitCommit)"
|
||||
#if DEBUG
|
||||
builtLine += " DEBUG BUILD"
|
||||
#endif
|
||||
builtLine += ")"
|
||||
}
|
||||
credits.append(NSAttributedString(string: "\n\(builtLine)", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
.foregroundColor: NSColor.secondaryLabelColor,
|
||||
]))
|
||||
}
|
||||
|
||||
let options: [NSApplication.AboutPanelOptionKey: Any] = [
|
||||
.applicationName: "CodexBar",
|
||||
.applicationVersion: versionString,
|
||||
.version: versionString,
|
||||
.credits: credits,
|
||||
.applicationIcon: (NSApplication.shared.applicationIconImage ?? NSImage()) as Any,
|
||||
]
|
||||
|
||||
NSApp.orderFrontStandardAboutPanel(options: options)
|
||||
|
||||
// Remove the focus ring around the app icon in the standard About panel for a cleaner look.
|
||||
if let aboutPanel = NSApp.windows.first(where: { $0.className.contains("About") }) {
|
||||
removeFocusRings(in: aboutPanel.contentView)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedBuildTimestamp(_ timestamp: String) -> String? {
|
||||
let parser = ISO8601DateFormatter()
|
||||
parser.formatOptions = [.withInternetDateTime]
|
||||
guard let date = parser.date(from: timestamp) else { return timestamp }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
formatter.locale = .current
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func removeFocusRings(in view: NSView?) {
|
||||
guard let view else { return }
|
||||
if let imageView = view as? NSImageView {
|
||||
imageView.focusRingType = .none
|
||||
}
|
||||
for subview in view.subviews {
|
||||
removeFocusRings(in: subview)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import AdaptiveRefreshCore
|
||||
import Foundation
|
||||
|
||||
/// Decides how long to wait before the next automatic usage refresh.
|
||||
/// Pure by construction: every signal arrives via `Input`, so the same
|
||||
/// input always yields the same `Decision` with no clock or system reads.
|
||||
struct AdaptiveRefreshPolicy: Sendable {
|
||||
struct Input: Sendable, Equatable {
|
||||
let now: Date
|
||||
let lastMenuOpenAt: Date?
|
||||
let lowPowerModeEnabled: Bool
|
||||
let thermalState: ProcessInfo.ThermalState
|
||||
}
|
||||
|
||||
typealias Reason = AdaptiveRefreshPolicyCore.Reason
|
||||
typealias Decision = AdaptiveRefreshPolicyCore.Decision
|
||||
|
||||
/// Representative cadence for consumers that need a single interval but cannot reach live
|
||||
/// signals (`ProviderRegistry` builds provider specs before a `UsageStore` exists). Matches
|
||||
/// `warmDelay`: the steady-state cadence while the user is active, which is when
|
||||
/// interval-derived heuristics such as the persistent-CLI-session idle window matter most.
|
||||
static let nominalIntervalForHeuristics = AdaptiveRefreshPolicyCore.nominalIntervalForHeuristics
|
||||
|
||||
func nextDelay(for input: Input) -> Decision {
|
||||
AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
|
||||
now: input.now,
|
||||
lastMenuOpenAt: input.lastMenuOpenAt,
|
||||
lowPowerModeEnabled: input.lowPowerModeEnabled,
|
||||
thermalPressure: Self.isConstrained(input.thermalState) ? .constrained : .nominal))
|
||||
}
|
||||
|
||||
private static func isConstrained(_ state: ProcessInfo.ThermalState) -> Bool {
|
||||
state == .serious || state == .critical
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct AgentSessionRemoteRefreshGate {
|
||||
private(set) var generation = 0
|
||||
private(set) var isInFlight = false
|
||||
private(set) var isPending = false
|
||||
|
||||
mutating func settingsDidChange() {
|
||||
self.generation += 1
|
||||
self.isPending = self.isInFlight
|
||||
}
|
||||
|
||||
mutating func begin() -> Int? {
|
||||
guard !self.isInFlight else {
|
||||
return nil
|
||||
}
|
||||
self.isInFlight = true
|
||||
self.isPending = false
|
||||
return self.generation
|
||||
}
|
||||
|
||||
mutating func finish(generation: Int) -> (shouldPublish: Bool, shouldRetry: Bool) {
|
||||
self.isInFlight = false
|
||||
let outcome = (generation == self.generation, self.isPending)
|
||||
self.isPending = false
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AgentSessionsStore {
|
||||
private let settings: SettingsStore
|
||||
private let localScanner: LocalAgentSessionScanner
|
||||
private let remoteFetcher: RemoteSessionFetcher
|
||||
@ObservationIgnored private var localRefreshTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var remoteRefreshTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var localRefreshInFlight = false
|
||||
@ObservationIgnored private var remoteRefreshGate = AgentSessionRemoteRefreshGate()
|
||||
@ObservationIgnored var onUpdate: (@MainActor () -> Void)?
|
||||
|
||||
private(set) var localSessions: [AgentSession] = []
|
||||
private(set) var remoteHosts: [RemoteSessionHostResult] = []
|
||||
private(set) var lastUpdatedAt: Date?
|
||||
|
||||
init(
|
||||
settings: SettingsStore,
|
||||
localScanner: LocalAgentSessionScanner = LocalAgentSessionScanner(),
|
||||
remoteFetcher: RemoteSessionFetcher = RemoteSessionFetcher())
|
||||
{
|
||||
self.settings = settings
|
||||
self.localScanner = localScanner
|
||||
self.remoteFetcher = remoteFetcher
|
||||
}
|
||||
|
||||
var totalCount: Int {
|
||||
self.localSessions.count + self.remoteHosts.reduce(0) { $0 + $1.sessions.count }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.localRefreshTask == nil, self.remoteRefreshTask == nil else { return }
|
||||
self.localRefreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.refreshLocal()
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
}
|
||||
}
|
||||
self.remoteRefreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.refreshRemote()
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.localRefreshTask?.cancel()
|
||||
self.remoteRefreshTask?.cancel()
|
||||
self.localRefreshTask = nil
|
||||
self.remoteRefreshTask = nil
|
||||
}
|
||||
|
||||
func settingsDidChange() {
|
||||
self.remoteRefreshGate.settingsDidChange()
|
||||
guard self.settings.agentSessionsEnabled else {
|
||||
self.localSessions = []
|
||||
self.remoteHosts = []
|
||||
self.onUpdate?()
|
||||
return
|
||||
}
|
||||
guard !SettingsStore.isRunningTests else { return }
|
||||
Task { [weak self] in
|
||||
await self?.refreshLocal()
|
||||
await self?.refreshRemote()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshOnMenuOpen() {
|
||||
guard self.settings.agentSessionsEnabled, !SettingsStore.isRunningTests else { return }
|
||||
Task { [weak self] in
|
||||
await self?.refreshLocal()
|
||||
await self?.refreshRemote()
|
||||
}
|
||||
}
|
||||
|
||||
func focus(_ session: AgentSession, remoteHost: String?) {
|
||||
if let remoteHost {
|
||||
Task {
|
||||
await self.remoteFetcher.focus(sessionID: session.id, host: remoteHost)
|
||||
}
|
||||
} else {
|
||||
_ = SessionWindowFocuser.focus(session)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshLocal() async {
|
||||
guard self.settings.agentSessionsEnabled, !self.localRefreshInFlight else { return }
|
||||
self.localRefreshInFlight = true
|
||||
let sessions = await self.localScanner.scan()
|
||||
self.localRefreshInFlight = false
|
||||
guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return }
|
||||
self.localSessions = sessions
|
||||
self.lastUpdatedAt = Date()
|
||||
self.onUpdate?()
|
||||
}
|
||||
|
||||
private func refreshRemote() async {
|
||||
guard self.settings.agentSessionsEnabled else { return }
|
||||
guard var generation = self.remoteRefreshGate.begin() else { return }
|
||||
while self.settings.agentSessionsEnabled {
|
||||
var hosts = self.manualHosts
|
||||
await hosts.append(contentsOf: self.remoteFetcher.discoveredHosts())
|
||||
let results = await self.remoteFetcher.fetch(hosts: hosts)
|
||||
let outcome = self.remoteRefreshGate.finish(generation: generation)
|
||||
guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return }
|
||||
if outcome.shouldPublish {
|
||||
self.remoteHosts = results
|
||||
self.lastUpdatedAt = Date()
|
||||
self.onUpdate?()
|
||||
}
|
||||
guard outcome.shouldRetry, let nextGeneration = self.remoteRefreshGate.begin() else { return }
|
||||
generation = nextGeneration
|
||||
}
|
||||
}
|
||||
|
||||
private var manualHosts: [String] {
|
||||
self.settings.agentSessionsManualHosts
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class AppNotifications {
|
||||
static let shared = AppNotifications()
|
||||
|
||||
private let centerProvider: @Sendable () -> UNUserNotificationCenter
|
||||
private let logger = CodexBarLog.logger(LogCategories.notifications)
|
||||
private var authorizationTask: Task<Bool, Never>?
|
||||
|
||||
init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) {
|
||||
self.centerProvider = centerProvider
|
||||
}
|
||||
|
||||
func requestAuthorizationOnStartup() {
|
||||
guard !Self.isRunningUnderTests else { return }
|
||||
_ = self.ensureAuthorizationTask()
|
||||
}
|
||||
|
||||
func post(
|
||||
idPrefix: String,
|
||||
title: String,
|
||||
body: String,
|
||||
badge: NSNumber? = nil,
|
||||
soundEnabled: Bool = true)
|
||||
{
|
||||
guard !Self.isRunningUnderTests else { return }
|
||||
let center = self.centerProvider()
|
||||
let logger = self.logger
|
||||
|
||||
Task { @MainActor in
|
||||
let granted = await self.ensureAuthorized()
|
||||
guard granted else {
|
||||
logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix])
|
||||
return
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = soundEnabled ? .default : nil
|
||||
content.badge = badge
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil)
|
||||
|
||||
logger.info("posting", metadata: ["prefix": idPrefix])
|
||||
do {
|
||||
try await center.add(request)
|
||||
} catch {
|
||||
let errorText = String(describing: error)
|
||||
logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func ensureAuthorizationTask() -> Task<Bool, Never> {
|
||||
if let authorizationTask { return authorizationTask }
|
||||
let task = Task { @MainActor in
|
||||
await self.requestAuthorization()
|
||||
}
|
||||
self.authorizationTask = task
|
||||
return task
|
||||
}
|
||||
|
||||
private func ensureAuthorized() async -> Bool {
|
||||
await self.ensureAuthorizationTask().value
|
||||
}
|
||||
|
||||
private func requestAuthorization() async -> Bool {
|
||||
if let existing = await self.notificationAuthorizationStatus() {
|
||||
if existing == .authorized || existing == .provisional {
|
||||
return true
|
||||
}
|
||||
if existing == .denied {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let center = self.centerProvider()
|
||||
return await withCheckedContinuation { continuation in
|
||||
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? {
|
||||
let center = self.centerProvider()
|
||||
return await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var isRunningUnderTests: Bool {
|
||||
// Swift Testing doesn't always set XCTest env vars, and removing XCTest imports from
|
||||
// the test target can make NSClassFromString("XCTestCase") return nil. If we're not
|
||||
// running inside an app bundle, treat it as "tests/headless" to avoid crashes when
|
||||
// accessing UNUserNotificationCenter.
|
||||
if Bundle.main.bundleURL.pathExtension != "app" { return true }
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if env["XCTestConfigurationFilePath"] != nil { return true }
|
||||
if env["TESTING_LIBRARY_VERSION"] != nil { return true }
|
||||
if env["SWIFT_TESTING"] != nil { return true }
|
||||
return NSClassFromString("XCTestCase") != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
enum ChartBarHoverSelection {
|
||||
static func accepts(distanceFromBarCenter: CGFloat, barHalfWidth: CGFloat, selectableCount: Int) -> Bool {
|
||||
selectableCount <= 1 || distanceFromBarCenter <= barHalfWidth
|
||||
}
|
||||
|
||||
static func nextCalendarDay(after date: Date, calendar: Calendar = .current) -> Date {
|
||||
calendar.date(byAdding: .day, value: 1, to: date) ?? date.addingTimeInterval(86400)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import CodexBarCore
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct ClaudeLoginRunner {
|
||||
static let loginArguments = ["auth", "login", "--claudeai"]
|
||||
private static let successMarkers = ["Successfully logged in", "Login successful", "Logged in successfully"]
|
||||
|
||||
enum Phase {
|
||||
case requesting
|
||||
case waitingBrowser
|
||||
}
|
||||
|
||||
struct Result {
|
||||
enum Outcome {
|
||||
case success
|
||||
case timedOut
|
||||
case failed(status: Int32)
|
||||
case missingBinary
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
let outcome: Outcome
|
||||
let output: String
|
||||
let authLink: String?
|
||||
}
|
||||
|
||||
static func run(
|
||||
timeout: TimeInterval = 120,
|
||||
binary: String = "claude",
|
||||
environment: [String: String]? = nil,
|
||||
onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result
|
||||
{
|
||||
await Task(priority: .userInitiated) {
|
||||
onPhaseChange(.requesting)
|
||||
do {
|
||||
let runResult = try self.runPTY(
|
||||
timeout: timeout,
|
||||
binary: binary,
|
||||
environment: environment,
|
||||
onPhaseChange: onPhaseChange)
|
||||
let link = self.firstLink(in: runResult.output)
|
||||
switch runResult.completion {
|
||||
case .processExited(status: 0):
|
||||
return Result(outcome: .success, output: runResult.output, authLink: link)
|
||||
case let .processExited(status):
|
||||
return Result(outcome: .failed(status: status), output: runResult.output, authLink: link)
|
||||
case .outputCondition where self.successMarkers.contains(where: runResult.output.contains):
|
||||
return Result(outcome: .success, output: runResult.output, authLink: link)
|
||||
case .outputCondition, .idleTimeout, .deadlineExceeded:
|
||||
return Result(outcome: .timedOut, output: runResult.output, authLink: link)
|
||||
}
|
||||
} catch LoginError.binaryNotFound {
|
||||
return Result(outcome: .missingBinary, output: "", authLink: nil)
|
||||
} catch let LoginError.timedOut(text) {
|
||||
return Result(outcome: .timedOut, output: text, authLink: self.firstLink(in: text))
|
||||
} catch {
|
||||
return Result(outcome: .launchFailed(error.localizedDescription), output: "", authLink: nil)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - PTY runner
|
||||
|
||||
private enum LoginError: Error {
|
||||
case binaryNotFound
|
||||
case timedOut(text: String)
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
private struct PTYRunResult {
|
||||
let output: String
|
||||
let completion: TTYCommandRunner.Result.Completion
|
||||
}
|
||||
|
||||
private static func runPTY(
|
||||
timeout: TimeInterval,
|
||||
binary: String,
|
||||
environment: [String: String]?,
|
||||
onPhaseChange: @escaping @Sendable (Phase) -> Void) throws -> PTYRunResult
|
||||
{
|
||||
let runner = TTYCommandRunner()
|
||||
var options = TTYCommandRunner.Options(rows: 50, cols: 160, timeout: timeout)
|
||||
options.extraArgs = self.loginArguments
|
||||
options.baseEnvironment = environment
|
||||
options.stopOnURL = false // keep running until CLI confirms
|
||||
options.stopOnSubstrings = self.successMarkers
|
||||
options.sendOnSubstrings = ["press ENTER to open in browser": "\r"]
|
||||
options.settleAfterStop = 0.35
|
||||
options.returnOnEmptyProcessExit = true
|
||||
do {
|
||||
let result = try runner.run(
|
||||
binary: binary,
|
||||
send: "",
|
||||
options: options,
|
||||
onURLDetected: { onPhaseChange(.waitingBrowser) })
|
||||
return PTYRunResult(output: result.text, completion: result.completion)
|
||||
} catch TTYCommandRunner.Error.binaryNotFound {
|
||||
throw LoginError.binaryNotFound
|
||||
} catch TTYCommandRunner.Error.timedOut {
|
||||
throw LoginError.timedOut(text: "")
|
||||
} catch let TTYCommandRunner.Error.launchFailed(msg) {
|
||||
throw LoginError.launchFailed(msg)
|
||||
} catch {
|
||||
throw LoginError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstLink(in text: String) -> String? {
|
||||
let pattern = #"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: nsRange),
|
||||
let range = Range(match.range, in: text) else { return nil }
|
||||
var url = String(text[range])
|
||||
while let last = url.unicodeScalars.last,
|
||||
CharacterSet(charactersIn: ".,;:)]}>\"'").contains(last)
|
||||
{
|
||||
url.unicodeScalars.removeLast()
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
enum MenuPasteboardCopy {
|
||||
typealias DeferredAction = @MainActor @Sendable () -> Void
|
||||
typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void
|
||||
typealias Writer = @MainActor @Sendable (String) -> Void
|
||||
|
||||
static func perform(
|
||||
_ text: String,
|
||||
scheduler: Scheduler = Self.schedule,
|
||||
writer: @escaping Writer = Self.write,
|
||||
completion: @escaping DeferredAction = {})
|
||||
{
|
||||
scheduler {
|
||||
writer(text)
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
private static func schedule(_ action: @escaping DeferredAction) {
|
||||
DispatchQueue.main.async(execute: action)
|
||||
}
|
||||
|
||||
private static func write(_ text: String) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
struct ClickToCopyOverlay: NSViewRepresentable {
|
||||
let copyText: String
|
||||
|
||||
func makeNSView(context: Context) -> ClickToCopyView {
|
||||
ClickToCopyView(copyText: self.copyText)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: ClickToCopyView, context: Context) {
|
||||
// Guard against no-op writes to avoid AppKit view invalidation on every
|
||||
// parent card SwiftUI diff (each MenuCardView body re-eval runs through
|
||||
// .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even
|
||||
// when copyText is unchanged).
|
||||
guard nsView.copyText != self.copyText else { return }
|
||||
nsView.copyText = self.copyText
|
||||
}
|
||||
}
|
||||
|
||||
final class ClickToCopyView: NSView {
|
||||
var copyText: String
|
||||
private let copyAction: (String) -> Void
|
||||
|
||||
init(
|
||||
copyText: String,
|
||||
copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) })
|
||||
{
|
||||
self.copyText = copyText
|
||||
self.copyAction = copyAction
|
||||
super.init(frame: .zero)
|
||||
self.wantsLayer = false
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
_ = event
|
||||
self.copyAction(self.copyText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum CodexAccountHealth: Equatable {
|
||||
case ok
|
||||
case needsReauth
|
||||
case workspaceDeactivated
|
||||
case missingAuth
|
||||
case unavailable
|
||||
|
||||
var label: String? {
|
||||
switch self {
|
||||
case .ok:
|
||||
nil
|
||||
case .needsReauth:
|
||||
"Needs re-auth"
|
||||
case .workspaceDeactivated:
|
||||
"Workspace deactivated"
|
||||
case .missingAuth:
|
||||
"Missing auth"
|
||||
case .unavailable:
|
||||
"Unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
static func status(for account: CodexVisibleAccount, error: String?) -> CodexAccountHealth {
|
||||
if let error {
|
||||
return self.status(forError: error)
|
||||
}
|
||||
if account.authenticationHealthLabel != nil {
|
||||
return .missingAuth
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
static func status(forError error: String) -> CodexAccountHealth {
|
||||
let normalized = error.lowercased()
|
||||
if normalized.contains("deactivated") {
|
||||
return .workspaceDeactivated
|
||||
}
|
||||
if normalized.contains("expired") ||
|
||||
normalized.contains("revoked") ||
|
||||
normalized.contains("unauthorized") ||
|
||||
normalized.contains("401")
|
||||
{
|
||||
return .needsReauth
|
||||
}
|
||||
if normalized.contains("missing"), normalized.contains("auth") {
|
||||
return .missingAuth
|
||||
}
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
enum CodexAccountPresentationOrdering {
|
||||
static func orderedAccounts(
|
||||
_ accounts: [CodexVisibleAccount],
|
||||
snapshots: [CodexAccountUsageSnapshot],
|
||||
activeVisibleAccountID: String?)
|
||||
-> [CodexVisibleAccount]
|
||||
{
|
||||
guard accounts.count > 1 else { return accounts }
|
||||
let snapshotByID = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.id, $0) })
|
||||
let rankedAccounts = accounts.enumerated().map { index, account in
|
||||
RankedAccount(
|
||||
account: account,
|
||||
rank: Rank(
|
||||
account: account,
|
||||
snapshot: snapshotByID[account.id],
|
||||
activeVisibleAccountID: activeVisibleAccountID,
|
||||
originalIndex: index))
|
||||
}
|
||||
let grouped = Dictionary(grouping: rankedAccounts, by: { Self.workspaceSortKey(for: $0.account) })
|
||||
return grouped.values.sorted { lhs, rhs in
|
||||
(lhs.map(\.rank).min() ?? .last) < (rhs.map(\.rank).min() ?? .last)
|
||||
}.flatMap { group in
|
||||
group.sorted { lhs, rhs in lhs.rank < rhs.rank }.map(\.account)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RankedAccount {
|
||||
let account: CodexVisibleAccount
|
||||
let rank: Rank
|
||||
}
|
||||
|
||||
private struct Rank: Comparable {
|
||||
static let last = Rank(bucket: Int.max, availabilityScore: -.greatestFiniteMagnitude, originalIndex: Int.max)
|
||||
|
||||
let bucket: Int
|
||||
let availabilityScore: Double
|
||||
let displaySort: String
|
||||
let originalIndex: Int
|
||||
|
||||
private init(bucket: Int, availabilityScore: Double, originalIndex: Int) {
|
||||
self.bucket = bucket
|
||||
self.availabilityScore = availabilityScore
|
||||
self.displaySort = ""
|
||||
self.originalIndex = originalIndex
|
||||
}
|
||||
|
||||
init(
|
||||
account: CodexVisibleAccount,
|
||||
snapshot: CodexAccountUsageSnapshot?,
|
||||
activeVisibleAccountID: String?,
|
||||
originalIndex: Int)
|
||||
{
|
||||
self.originalIndex = originalIndex
|
||||
self.displaySort = account.menuDisplayName.lowercased()
|
||||
|
||||
if account.id == activeVisibleAccountID {
|
||||
self.bucket = 0
|
||||
} else {
|
||||
let health = CodexAccountHealth.status(for: account, error: snapshot?.error)
|
||||
if health != .ok {
|
||||
self.bucket = health == .missingAuth ? 4 : 3
|
||||
} else if let availability = Self.availability(snapshot?.snapshot), availability <= 0 {
|
||||
self.bucket = 2
|
||||
} else {
|
||||
self.bucket = 1
|
||||
}
|
||||
}
|
||||
self.availabilityScore = Self.availability(snapshot?.snapshot) ?? -1
|
||||
}
|
||||
|
||||
static func < (lhs: Rank, rhs: Rank) -> Bool {
|
||||
if lhs.bucket != rhs.bucket { return lhs.bucket < rhs.bucket }
|
||||
if lhs.availabilityScore != rhs.availabilityScore {
|
||||
return lhs.availabilityScore > rhs.availabilityScore
|
||||
}
|
||||
if lhs.displaySort != rhs.displaySort { return lhs.displaySort < rhs.displaySort }
|
||||
return lhs.originalIndex < rhs.originalIndex
|
||||
}
|
||||
|
||||
private static func availability(_ snapshot: UsageSnapshot?) -> Double? {
|
||||
guard let snapshot else { return nil }
|
||||
let session = snapshot.primary?.remainingPercent
|
||||
let weekly = snapshot.secondary?.remainingPercent
|
||||
return switch (session, weekly) {
|
||||
case let (.some(session), .some(weekly)):
|
||||
min(session, weekly)
|
||||
case let (.some(session), .none):
|
||||
session
|
||||
case let (.none, .some(weekly)):
|
||||
weekly
|
||||
case (.none, .none):
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func workspaceSortKey(for account: CodexVisibleAccount) -> String {
|
||||
if let workspaceAccountID = account.workspaceAccountID, !workspaceAccountID.isEmpty {
|
||||
return workspaceAccountID.lowercased()
|
||||
}
|
||||
return account.menuWorkspaceLabel?.lowercased() ?? "personal"
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexAccountWorkspaceSection: Equatable {
|
||||
let title: String
|
||||
let accounts: [CodexVisibleAccount]
|
||||
}
|
||||
|
||||
extension [CodexVisibleAccount] {
|
||||
func codexWorkspaceSections() -> [CodexAccountWorkspaceSection] {
|
||||
guard !self.isEmpty else { return [] }
|
||||
var sections: [CodexAccountWorkspaceSection] = []
|
||||
for account in self {
|
||||
let title = account.menuWorkspaceLabel ?? "Personal"
|
||||
if let index = sections.firstIndex(where: { $0.title == title }) {
|
||||
var accounts = sections[index].accounts
|
||||
accounts.append(account)
|
||||
sections[index] = CodexAccountWorkspaceSection(title: title, accounts: accounts)
|
||||
} else {
|
||||
sections.append(CodexAccountWorkspaceSection(title: title, accounts: [account]))
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct CodexSystemAccountPromotionUserFacingError: Error, Equatable {
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CodexAccountPromotionCoordinator {
|
||||
let service: CodexAccountPromotionService
|
||||
weak var managedAccountCoordinator: ManagedCodexAccountCoordinator?
|
||||
private(set) var isAuthenticatingLiveAccount = false
|
||||
private(set) var isPromotingSystemAccount = false
|
||||
private(set) var userFacingError: CodexSystemAccountPromotionUserFacingError?
|
||||
|
||||
init(
|
||||
service: CodexAccountPromotionService,
|
||||
managedAccountCoordinator: ManagedCodexAccountCoordinator? = nil)
|
||||
{
|
||||
self.service = service
|
||||
self.managedAccountCoordinator = managedAccountCoordinator
|
||||
}
|
||||
|
||||
convenience init(
|
||||
settingsStore: SettingsStore,
|
||||
usageStore: UsageStore,
|
||||
managedAccountCoordinator: ManagedCodexAccountCoordinator? = nil)
|
||||
{
|
||||
self.init(
|
||||
service: CodexAccountPromotionService(settingsStore: settingsStore, usageStore: usageStore),
|
||||
managedAccountCoordinator: managedAccountCoordinator)
|
||||
}
|
||||
|
||||
func promote(managedAccountID: UUID)
|
||||
async -> Result<CodexAccountPromotionResult, CodexSystemAccountPromotionUserFacingError>
|
||||
{
|
||||
self.userFacingError = nil
|
||||
|
||||
guard !self.isInteractionBlocked() else {
|
||||
let error = Self.interactionBlockedError()
|
||||
self.userFacingError = error
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
self.isPromotingSystemAccount = true
|
||||
defer { self.isPromotingSystemAccount = false }
|
||||
|
||||
do {
|
||||
let result = try await self.service.promoteManagedAccount(id: managedAccountID)
|
||||
return .success(result)
|
||||
} catch {
|
||||
let mapped = Self.mapUserFacingError(error)
|
||||
self.userFacingError = mapped
|
||||
return .failure(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
self.userFacingError = nil
|
||||
}
|
||||
|
||||
func setLiveReauthenticationInProgress(_ isInProgress: Bool) {
|
||||
self.isAuthenticatingLiveAccount = isInProgress
|
||||
}
|
||||
|
||||
func isInteractionBlocked() -> Bool {
|
||||
self.isPromotingSystemAccount ||
|
||||
self.isAuthenticatingLiveAccount ||
|
||||
self.managedAccountCoordinator?.hasConflictingManagedAccountOperationInFlight == true
|
||||
}
|
||||
|
||||
private static func interactionBlockedError() -> CodexSystemAccountPromotionUserFacingError {
|
||||
CodexSystemAccountPromotionUserFacingError(
|
||||
title: L("Could not switch system account"),
|
||||
message: L("Finish the current managed account change before switching the system account."))
|
||||
}
|
||||
|
||||
static func mapUserFacingError(_ error: Error) -> CodexSystemAccountPromotionUserFacingError {
|
||||
let title = L("Could not switch system account")
|
||||
|
||||
if let error = error as? CodexAccountPromotionError {
|
||||
let message = switch error {
|
||||
case .targetManagedAccountNotFound:
|
||||
L("That account is no longer available in CodexBar. Refresh the account list and try again.")
|
||||
case .targetManagedAccountAuthMissing:
|
||||
L("CodexBar could not find saved auth for that account. Re-authenticate it and try again.")
|
||||
case .targetManagedAccountAuthUnreadable:
|
||||
L("CodexBar could not read saved auth for that account. Re-authenticate it and try again.")
|
||||
case .liveAccountUnreadable:
|
||||
L("CodexBar could not read the current system account on this Mac.")
|
||||
case .liveAccountMissingIdentityForPreservation:
|
||||
L("CodexBar could not safely preserve the current system account before switching.")
|
||||
case .liveAccountAPIKeyOnlyUnsupported:
|
||||
L("CodexBar can't replace a system account that is signed in with an API key only setup.")
|
||||
case .displacedLiveManagedAccountConflict:
|
||||
L(
|
||||
"CodexBar found another managed account that already uses the current system account. " +
|
||||
"Resolve the duplicate account before switching.")
|
||||
case .displacedLiveImportFailed:
|
||||
L("CodexBar could not save the current system account before switching.")
|
||||
case .managedStoreCommitFailed:
|
||||
L("CodexBar could not update managed account storage.")
|
||||
case .liveAuthSwapFailed:
|
||||
L("CodexBar could not replace the live Codex auth on this Mac.")
|
||||
}
|
||||
|
||||
return CodexSystemAccountPromotionUserFacingError(title: title, message: message)
|
||||
}
|
||||
|
||||
return CodexSystemAccountPromotionUserFacingError(title: title, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user