diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 5e28cdf207..0cdfd2de80 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -55,6 +55,18 @@ "path": "codex-responses-api-proxy.exe" } } + }, + "codex-notifier": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-notifier-aarch64-apple-darwin\\.zst$", + "path": "codex-notifier" + }, + "macos-x86_64": { + "regex": "^codex-notifier-x86_64-apple-darwin\\.zst$", + "path": "codex-notifier" + } + } } } } diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 5beadd55c4..b883fee126 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -100,6 +100,10 @@ jobs: - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + - if: ${{ matrix.runner == 'macos-15-xlarge' }} + name: Build notifier helper + run: cargo build --target ${{ matrix.target }} --release -p codex-notifier + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash @@ -201,7 +205,12 @@ jobs: keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") fi - for binary in codex codex-responses-api-proxy; do + binaries=(codex codex-responses-api-proxy) + if [[ -f "target/${{ matrix.target }}/release/codex-notifier" ]]; then + binaries+=(codex-notifier) + fi + + for binary in "${binaries[@]}"; do path="target/${{ matrix.target }}/release/${binary}" codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done @@ -268,6 +277,9 @@ jobs: notarize_binary "codex" notarize_binary "codex-responses-api-proxy" + if [[ -f "target/${{ matrix.target }}/release/codex-notifier" ]]; then + notarize_binary "codex-notifier" + fi - name: Stage artifacts shell: bash @@ -281,6 +293,9 @@ jobs: else cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" + if [[ -f target/${{ matrix.target }}/release/codex-notifier ]]; then + cp target/${{ matrix.target }}/release/codex-notifier "$dest/codex-notifier-${{ matrix.target }}" + fi fi - if: ${{ matrix.runner == 'windows-11-arm' }} diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index ef96bef2ee..f00e37f6fd 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -16,13 +16,14 @@ RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript" PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { - "codex": ["codex", "rg"], + "codex": ["codex", "codex-notifier", "rg"], "codex-responses-api-proxy": ["codex-responses-api-proxy"], "codex-sdk": ["codex"], } COMPONENT_DEST_DIR: dict[str, str] = { "codex": "codex", "codex-responses-api-proxy": "codex-responses-api-proxy", + "codex-notifier": "codex-notifier", "rg": "path", } diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 8d3909c9e1..74b744c429 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -34,8 +34,11 @@ BINARY_TARGETS = ( @dataclass(frozen=True) class BinaryComponent: artifact_prefix: str # matches the artifact filename prefix (e.g. codex-.zst) - dest_dir: str # directory under vendor// where the binary is installed - binary_basename: str # executable name inside dest_dir (before optional .exe) + dest_dir: str # directory under vendor// where the binary/artifact is installed + binary_basename: str # executable or directory name inside dest_dir (before optional .exe) + archive_format: str = "zst" # one of "zst", "zip", "tar.gz" + archive_member: str | None = None # optional archive member to extract + supported_targets: tuple[str, ...] | None = None # restrict installation to these targets BINARY_COMPONENTS = { @@ -49,6 +52,15 @@ BINARY_COMPONENTS = { dest_dir="codex-responses-api-proxy", binary_basename="codex-responses-api-proxy", ), + "codex-notifier": BinaryComponent( + artifact_prefix="codex-notifier", + dest_dir="codex-notifier", + binary_basename="codex-notifier", + supported_targets=( + "x86_64-apple-darwin", + "aarch64-apple-darwin", + ), + ), } RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ @@ -101,7 +113,7 @@ def main() -> int: vendor_dir = codex_cli_root / VENDOR_DIR_NAME vendor_dir.mkdir(parents=True, exist_ok=True) - components = args.components or ["codex", "rg"] + components = args.components or ["codex", "codex-notifier", "rg"] workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip() if not workflow_url: @@ -218,11 +230,19 @@ def install_binary_components( return for component in selected_components: + component_targets = [ + target + for target in targets + if not component.supported_targets or target in component.supported_targets + ] + if not component_targets: + continue + print( f"Installing {component.binary_basename} binaries for targets: " - + ", ".join(targets) + + ", ".join(component_targets) ) - max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) + max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1))) with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit( @@ -232,7 +252,7 @@ def install_binary_components( target, component, ): target - for target in targets + for target in component_targets } for future in as_completed(futures): installed_path = future.result() @@ -246,7 +266,7 @@ def _install_single_binary( component: BinaryComponent, ) -> Path: artifact_subdir = artifacts_dir / target - archive_name = _archive_name_for_target(component.artifact_prefix, target) + archive_name = _archive_name_for_target(component, target) archive_path = artifact_subdir / archive_name if not archive_path.exists(): raise FileNotFoundError(f"Expected artifact not found: {archive_path}") @@ -255,20 +275,29 @@ def _install_single_binary( dest_dir.mkdir(parents=True, exist_ok=True) binary_name = ( - f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename + f"{component.binary_basename}.exe" + if "windows" in target and component.archive_format == "zst" + else component.binary_basename ) dest = dest_dir / binary_name dest.unlink(missing_ok=True) - extract_archive(archive_path, "zst", None, dest) - if "windows" not in target: + extract_archive( + archive_path, component.archive_format, component.archive_member, dest + ) + if "windows" not in target and dest.is_file(): dest.chmod(0o755) return dest -def _archive_name_for_target(artifact_prefix: str, target: str) -> str: +def _archive_name_for_target(component: BinaryComponent, target: str) -> str: + fmt = component.archive_format + if fmt == "zip": + return f"{component.artifact_prefix}-{target}.zip" + if fmt == "tar.gz": + return f"{component.artifact_prefix}-{target}.tar.gz" if "windows" in target: - return f"{artifact_prefix}-{target}.exe.zst" - return f"{artifact_prefix}-{target}.zst" + return f"{component.artifact_prefix}-{target}.exe.zst" + return f"{component.artifact_prefix}-{target}.zst" def _fetch_single_rg( @@ -346,16 +375,25 @@ def extract_archive( return if archive_format == "zip": - if not archive_member: - raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.") with zipfile.ZipFile(archive_path) as archive: - try: - with archive.open(archive_member) as src, open(dest, "wb") as out: - shutil.copyfileobj(src, out) - except KeyError as exc: - raise RuntimeError( - f"Entry '{archive_member}' not found in archive {archive_path}." - ) from exc + if archive_member: + try: + with archive.open(archive_member) as src, open(dest, "wb") as out: + shutil.copyfileobj(src, out) + except KeyError as exc: + raise RuntimeError( + f"Entry '{archive_member}' not found in archive {archive_path}." + ) from exc + else: + with tempfile.TemporaryDirectory() as tmp_dir_str: + tmp_dir = Path(tmp_dir_str) + archive.extractall(tmp_dir) + entries = [p for p in tmp_dir.iterdir() if not p.name.startswith(".__")] + if len(entries) != 1: + raise RuntimeError( + f"Expected a single top-level entry in {archive_path}, found {len(entries)}" + ) + shutil.move(str(entries[0]), dest) return raise RuntimeError(f"Unsupported archive format '{archive_format}'.") diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7d54062f8e..c68dabc3aa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1299,6 +1299,16 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-notifier" +version = "0.0.0" +dependencies = [ + "anyhow", + "cc", + "serde", + "serde_json", +] + [[package]] name = "codex-ollama" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a2c52f44ad..54b44826f1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -12,6 +12,7 @@ members = [ "cloud-tasks", "cloud-tasks-client", "cli", + "notifier", "common", "core", "exec", diff --git a/codex-rs/README.md b/codex-rs/README.md index 84b08920e3..b220938bcd 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t ### Notifications -On macOS, the CLI now ships with a signed Notification Center integration, so you’ll get trusted desktop alerts (with the Codex icon) as soon as a turn completes—no extra tooling required. If you want to customize the behaviour or are on another platform, you can still point `notify` at your own script; see the [notify documentation](../docs/config.md#notify) for an example. +On macOS, the CLI installs a signed helper app (`~/.codex/bin/CodexNotifier.app`) that delivers Notification Center alerts with the Codex icon, so you’ll get trusted banners as soon as a turn completes—no extra tooling required. Set `CODEX_NOTIFIER_APP` if you need to point at a custom build. If you want to customise the behaviour or are on another platform, you can still point `notify` at your own script; see the [notify documentation](../docs/config.md#notify) for an example. ### `codex exec` to run Codex programmatically/non-interactively diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 48b56b92ed..16c9ca6eb2 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -91,7 +91,7 @@ seccompiler = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" -mac-notification-sys = "0.6.6" +mac-notification-sys = "=0.6.6" # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] diff --git a/codex-rs/core/src/user_notification.rs b/codex-rs/core/src/user_notification.rs index 9e7be95096..e0bc9217ab 100644 --- a/codex-rs/core/src/user_notification.rs +++ b/codex-rs/core/src/user_notification.rs @@ -109,20 +109,254 @@ mod tests { mod macos { use super::UserNotification; use crate::config; + use anyhow::Context; + use anyhow::Result; + use anyhow::anyhow; use mac_notification_sys::Notification; use mac_notification_sys::NotificationResponse; + use serde_json; + use std::env; use std::fs; + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; use std::path::PathBuf; + use std::process::Command; + use tempfile::NamedTempFile; use tracing::debug; use tracing::warn; + const HELPER_ENV: &str = "CODEX_NOTIFIER_APP"; + const HELPER_BUNDLE_NAME: &str = "CodexNotifier.app"; + const HELPER_EXECUTABLE: &str = "Contents/MacOS/codex-notifier"; + const INFO_PLIST_TEMPLATE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../notifier/Resources/Info.plist" + )); + const ICON_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../notifier/Resources/Codex.icns" + )); + #[derive(Debug)] pub(crate) struct MacNotifier { - icon_path: Option, + helper: Option, + legacy: LegacyNotifier, } impl MacNotifier { pub(crate) fn new() -> Self { + let helper = match HelperApp::discover() { + Ok(app) => { + debug!( + "using Codex helper notifier at {}", + app.bundle_path.display() + ); + Some(app) + } + Err(err) => { + debug!("no helper notifier available: {err:?}"); + None + } + }; + + Self { + helper, + legacy: LegacyNotifier::new(), + } + } + + pub(crate) fn notify(&self, notification: &UserNotification) { + if env::var("CODEX_SANDBOX").is_ok() { + return; + } + if env::var("CI").is_ok() { + return; + } + + if let Some(helper) = &self.helper { + if let Err(err) = helper.notify(notification) { + warn!("failed to send macOS notification via helper: {err:?}"); + } else { + return; + } + } + + self.legacy.notify(notification); + } + } + + #[derive(Debug)] + struct HelperApp { + bundle_path: PathBuf, + } + + impl HelperApp { + fn discover() -> Result { + if let Ok(custom) = env::var(HELPER_ENV) { + let candidate = PathBuf::from(custom); + if Self::is_valid_bundle(&candidate) { + return Ok(Self { + bundle_path: candidate, + }); + } + } + + for path in Self::candidate_paths()? { + if Self::is_valid_bundle(&path) { + return Ok(Self { bundle_path: path }); + } + } + + if let Ok(installed) = Self::install_to_codex_home() { + if Self::is_valid_bundle(&installed) { + return Ok(Self { + bundle_path: installed, + }); + } + } + + Err(anyhow!("helper bundle not found")) + } + + fn candidate_paths() -> Result> { + let mut candidates = Vec::new(); + + if let Ok(home) = config::find_codex_home() { + candidates.push(home.join(HELPER_BUNDLE_NAME)); + candidates.push(home.join("bin").join(HELPER_BUNDLE_NAME)); + } + + if let Ok(exe) = env::current_exe() { + if let Some(parent) = exe.parent() { + candidates.push(parent.join(HELPER_BUNDLE_NAME)); + if let Some(grand) = parent.parent() { + candidates.push(grand.join(HELPER_BUNDLE_NAME)); + } + } + } + + Ok(candidates) + } + + fn install_to_codex_home() -> Result { + let mut base = config::find_codex_home()?; + base.push("bin"); + fs::create_dir_all(&base)?; + let bundle_path = base.join(HELPER_BUNDLE_NAME); + if !Self::is_valid_bundle(&bundle_path) { + Self::write_bundle(&bundle_path)?; + } + Ok(bundle_path) + } + + fn write_bundle(destination: &Path) -> Result<()> { + let contents = destination.join("Contents"); + let macos_dir = contents.join("MacOS"); + let resources_dir = contents.join("Resources"); + fs::create_dir_all(&macos_dir)?; + fs::create_dir_all(&resources_dir)?; + + fs::write(contents.join("Info.plist"), INFO_PLIST_TEMPLATE) + .context("failed to write Info.plist")?; + fs::write(resources_dir.join("Codex.icns"), ICON_BYTES) + .context("failed to write Codex.icns")?; + + let binary_src = Self::locate_binary().context("codex-notifier binary not found")?; + let binary_dest = macos_dir.join("codex-notifier"); + fs::copy(&binary_src, &binary_dest) + .with_context(|| format!("failed to copy notifier binary from {binary_src:?}"))?; + + let mut perms = fs::metadata(&binary_dest)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&binary_dest, perms)?; + + Ok(()) + } + + fn locate_binary() -> Result { + for candidate in Self::binary_candidate_paths()? { + if candidate.is_file() { + return Ok(candidate); + } + if candidate.is_dir() { + let nested = candidate.join("codex-notifier"); + if nested.is_file() { + return Ok(nested); + } + } + } + Err(anyhow!("codex-notifier binary unavailable")) + } + + fn binary_candidate_paths() -> Result> { + let mut paths = Vec::new(); + + if let Ok(exe) = env::current_exe() { + if let Some(parent) = exe.parent() { + paths.push(parent.join("codex-notifier")); + paths.push(parent.join("../codex-notifier")); + paths.push(parent.join("../../codex-notifier")); + if let Some(grand) = parent.parent() { + paths.push(grand.join("codex-notifier")); + paths.push(grand.join("release").join("codex-notifier")); + } + } + } + + if let Ok(home) = config::find_codex_home() { + paths.push(home.join("bin").join("codex-notifier")); + } + + Ok(paths) + } + + fn is_valid_bundle(path: &Path) -> bool { + let exec = path.join(HELPER_EXECUTABLE); + exec.is_file() + } + + fn notify(&self, notification: &UserNotification) -> Result<()> { + let payload = serde_json::to_vec(notification)?; + + let mut temp = NamedTempFile::new().context("failed to create temp payload file")?; + temp.as_file_mut() + .write_all(&payload) + .context("failed to write payload")?; + temp.flush().ok(); + + let temp_path = temp.into_temp_path(); + let payload_path = temp_path + .keep() + .context("failed to persist payload file for helper app")?; + + let status = Command::new("open") + .arg("-n") + .arg(&self.bundle_path) + .arg("--args") + .arg("--payload-file") + .arg(&payload_path) + .status() + .with_context(|| { + format!("failed to invoke open for {}", self.bundle_path.display()) + })?; + + if !status.success() { + let _ = fs::remove_file(&payload_path); + return Err(anyhow!("open exited with status {status:?}")); + } + + Ok(()) + } + } + + #[derive(Debug)] + struct LegacyNotifier { + icon_path: Option, + } + + impl LegacyNotifier { + fn new() -> Self { if let Err(err) = mac_notification_sys::set_application("com.openai.codex") { warn!("failed to register bundle id for notifications: {err}"); } @@ -136,17 +370,7 @@ mod macos { Self { icon_path } } - pub(crate) fn notify(&self, notification: &UserNotification) { - if std::env::var("CODEX_SANDBOX").is_ok() { - // Avoid firing real notifications when running inside our sandboxed Seatbelt harness. - return; - } - - if std::env::var("CI").is_ok() { - // Skip macOS notifications when running in CI environments. - return; - } - + fn notify(&self, notification: &UserNotification) { let (title, subtitle, message) = match notification { UserNotification::AgentTurnComplete { last_assistant_message, @@ -191,7 +415,7 @@ mod macos { debug!("Codex notification clicked"); } Ok(NotificationResponse::None) => {} - Err(err) => warn!("failed to deliver macOS notification: {err}"), + Err(err) => warn!("failed to deliver macOS notification via legacy path: {err}"), } } @@ -213,6 +437,9 @@ mod macos { if needs_write { fs::write(&path, ICON_BYTES)?; + let mut perms = fs::metadata(&path)?.permissions(); + perms.set_mode(0o644); + fs::set_permissions(&path, perms)?; } Ok(path) diff --git a/codex-rs/notifier/Cargo.toml b/codex-rs/notifier/Cargo.toml new file mode 100644 index 0000000000..a92f4ec6d0 --- /dev/null +++ b/codex-rs/notifier/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "codex-notifier" +version = { workspace = true } +edition = "2024" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[build-dependencies] +cc = "1.0" diff --git a/codex-rs/notifier/Resources/Codex.icns b/codex-rs/notifier/Resources/Codex.icns new file mode 100644 index 0000000000..b08b0eac2d Binary files /dev/null and b/codex-rs/notifier/Resources/Codex.icns differ diff --git a/codex-rs/notifier/Resources/Info.plist b/codex-rs/notifier/Resources/Info.plist new file mode 100644 index 0000000000..2c11fa49e8 --- /dev/null +++ b/codex-rs/notifier/Resources/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + codex-notifier + CFBundleIdentifier + com.openai.codex.notifier + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Codex Notifier + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + LSMinimumSystemVersion + 14.0 + LSUIElement + + CFBundleIconFile + Codex + NSUserNotificationUsageDescription + Codex CLI uses notifications to tell you when a turn completes. + + diff --git a/codex-rs/notifier/build.rs b/codex-rs/notifier/build.rs new file mode 100644 index 0000000000..b3ca1b4e10 --- /dev/null +++ b/codex-rs/notifier/build.rs @@ -0,0 +1,21 @@ +fn main() { + #[cfg(target_os = "macos")] + { + use std::path::PathBuf; + + let source = PathBuf::from("src/macos/notification.mm"); + println!("cargo:rerun-if-changed={}", source.display()); + + let mut build = cc::Build::new(); + build + .cpp(true) + .flag("-fobjc-arc") + .flag("-fmodules") + .file(&source); + build.compile("codex_macos_notification"); + + println!("cargo:rustc-link-lib=framework=UserNotifications"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=AppKit"); + } +} diff --git a/codex-rs/notifier/src/macos/notification.mm b/codex-rs/notifier/src/macos/notification.mm new file mode 100644 index 0000000000..a2e1309851 --- /dev/null +++ b/codex-rs/notifier/src/macos/notification.mm @@ -0,0 +1,153 @@ +#include +#include +#include +#include +#include + +@interface CodexNotificationDelegate : NSObject +@end + +@implementation CodexNotificationDelegate +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler { + completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + didReceiveNotificationResponse:(UNNotificationResponse *)response + withCompletionHandler:(void (^)(void))completionHandler { + completionHandler(); +} +@end + +static void install_delegate(UNUserNotificationCenter *center) { + static dispatch_once_t onceToken; + static CodexNotificationDelegate *delegate = nil; + dispatch_once(&onceToken, ^{ + delegate = [CodexNotificationDelegate new]; + center.delegate = delegate; + }); +} + +static BOOL ensure_authorization(UNUserNotificationCenter *center) { + dispatch_semaphore_t settings_wait = dispatch_semaphore_create(0); + __block UNAuthorizationStatus status = UNAuthorizationStatusNotDetermined; + + [center + getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) { + status = settings.authorizationStatus; + dispatch_semaphore_signal(settings_wait); + }]; + + dispatch_semaphore_wait(settings_wait, DISPATCH_TIME_FOREVER); + + if (status == UNAuthorizationStatusDenied) { + fprintf(stderr, "[codex-notifier] notification authorization currently denied\n"); + return NO; + } + + if (status == UNAuthorizationStatusAuthorized || + status == UNAuthorizationStatusProvisional) { + return YES; + } + + dispatch_semaphore_t request_wait = dispatch_semaphore_create(0); + __block BOOL granted = NO; + + [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound | + UNAuthorizationOptionBadge) + completionHandler:^(BOOL success, NSError *error) { + granted = success; + if (error) { + fprintf(stderr, "[codex-notifier] authorization request error: %s\n", + error.localizedDescription.UTF8String); + } + dispatch_semaphore_signal(request_wait); + }]; + + dispatch_semaphore_wait(request_wait, DISPATCH_TIME_FOREVER); + return granted; +} + +extern "C" int codex_post_user_notification(const char *title_c, + const char *subtitle_c, + const char *body_c, + const char *icon_path_c) { + setenv("__CFBundleIdentifier", "com.openai.codex.notifier", 0); + + @autoreleasepool { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + if (!center) { + fprintf(stderr, "[codex-notifier] UNUserNotificationCenter unavailable\n"); + return -10; + } + + install_delegate(center); + + if (!ensure_authorization(center)) { + return -11; + } + + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + if (!content) { + fprintf(stderr, "[codex-notifier] failed to allocate notification content\n"); + return -12; + } + + if (title_c && title_c[0] != '\0') { + content.title = [NSString stringWithUTF8String:title_c]; + } else { + content.title = @"Codex CLI"; + } + + if (subtitle_c && subtitle_c[0] != '\0') { + content.subtitle = [NSString stringWithUTF8String:subtitle_c]; + } + + if (body_c && body_c[0] != '\0') { + content.body = [NSString stringWithUTF8String:body_c]; + } + + if (icon_path_c && icon_path_c[0] != '\0') { + NSError *attachmentError = nil; + NSString *path = [NSString stringWithUTF8String:icon_path_c]; + UNNotificationAttachment *attachment = + [UNNotificationAttachment attachmentWithIdentifier:@"codex-icon" + URL:[NSURL fileURLWithPath:path] + options:nil + error:&attachmentError]; + if (attachment) { + content.attachments = @[attachment]; + } else if (attachmentError) { + fprintf(stderr, "[codex-notifier] attachment error: %s\n", + attachmentError.localizedDescription.UTF8String); + } + } + + UNTimeIntervalNotificationTrigger *trigger = + [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:0.1 repeats:NO]; + NSString *identifier = + [NSString stringWithFormat:@"com.openai.codex.notifier.%@", [[NSUUID UUID] UUIDString]]; + UNNotificationRequest *request = + [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:trigger]; + + dispatch_semaphore_t submit_wait = dispatch_semaphore_create(0); + __block NSError *submit_error = nil; + [center addNotificationRequest:request + withCompletionHandler:^(NSError *error) { + submit_error = error; + dispatch_semaphore_signal(submit_wait); + }]; + + dispatch_semaphore_wait(submit_wait, DISPATCH_TIME_FOREVER); + + if (submit_error) { + fprintf(stderr, "[codex-notifier] addNotificationRequest error: %s\n", + submit_error.localizedDescription.UTF8String); + return -13; + } + + return 0; + } +} diff --git a/codex-rs/notifier/src/main.rs b/codex-rs/notifier/src/main.rs new file mode 100644 index 0000000000..42a80cb243 --- /dev/null +++ b/codex-rs/notifier/src/main.rs @@ -0,0 +1,161 @@ +#![cfg_attr(not(target_os = "macos"), allow(unused))] + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use std::env; +use std::ffi::CString; +use std::fs; +use std::io::Read; +use std::io::{self}; +use std::os::raw::c_char; +use std::path::Path; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +enum NotificationPayload { + #[serde(rename_all = "kebab-case")] + AgentTurnComplete { + thread_id: String, + turn_id: String, + cwd: String, + #[serde(default)] + input_messages: Vec, + #[serde(default)] + last_assistant_message: Option, + }, +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn codex_post_user_notification( + title: *const c_char, + subtitle: *const c_char, + body: *const c_char, + icon_path: *const c_char, + ) -> i32; +} + +fn main() -> Result<()> { + real_main() +} + +#[cfg(target_os = "macos")] +fn real_main() -> Result<()> { + let payload = read_payload()?; + let (title, subtitle, body) = render_notification(&payload)?; + dispatch_notification(&title, subtitle.as_deref(), &body)?; + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn real_main() -> Result<()> { + bail!("codex-notifier is only supported on macOS"); +} + +#[cfg(target_os = "macos")] +fn read_payload() -> Result { + let mut args = env::args().skip(1); + let mut payload_json: Option = None; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--payload" => { + let Some(value) = args.next() else { + bail!("missing value for --payload"); + }; + payload_json = Some(value); + break; + } + "--payload-file" => { + let Some(path) = args.next() else { + bail!("missing value for --payload-file"); + }; + payload_json = Some( + fs::read_to_string(Path::new(&path)) + .with_context(|| format!("failed to read payload file at {path}"))?, + ); + // Best-effort cleanup to avoid cluttering temp directories. + let _ = fs::remove_file(path); + break; + } + // Ignore arguments injected by `open -a`. + arg if arg.starts_with("-Apple") || arg == "-NSDocumentRevisionsDebugMode" => { + let _ = args.next(); // consume companion value if present + } + _ => { + // Unrecognised argument – continue scanning in case the payload flag appears later. + } + } + } + + let json = if let Some(payload) = payload_json { + payload + } else { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .context("failed to read payload from stdin")?; + buf + }; + + serde_json::from_str(&json).context("failed to parse notification payload JSON") +} + +#[cfg(target_os = "macos")] +fn render_notification(payload: &NotificationPayload) -> Result<(String, Option, String)> { + match payload { + NotificationPayload::AgentTurnComplete { + input_messages, + last_assistant_message, + cwd, + .. + } => { + let title = "Codex CLI".to_string(); + let subtitle = last_assistant_message + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| Some("Turn complete".to_string())); + + let mut body = String::new(); + if !input_messages.is_empty() { + body.push_str(&input_messages.join(" ")); + } + if body.is_empty() { + body.push_str("Agent turn finished"); + } + body.push_str("\n"); + body.push_str(cwd); + + Ok((title, subtitle, body)) + } + } +} + +#[cfg(target_os = "macos")] +fn dispatch_notification(title: &str, subtitle: Option<&str>, body: &str) -> Result<()> { + let title_c = CString::new(title)?; + let subtitle_c = subtitle + .map(|s| CString::new(s)) + .transpose() + .context("invalid subtitle string")?; + let body_c = CString::new(body)?; + + let icon_ptr = std::ptr::null(); + + let code = unsafe { + codex_post_user_notification( + title_c.as_ptr(), + subtitle_c.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()), + body_c.as_ptr(), + icon_ptr, + ) + }; + + if code != 0 { + bail!("codex_post_user_notification returned error code {code}"); + } + Ok(()) +} diff --git a/docs/config.md b/docs/config.md index 8e3fd3df16..5f560776ba 100644 --- a/docs/config.md +++ b/docs/config.md @@ -617,6 +617,8 @@ function without the extra dependencies. On macOS, Codex now emits native Notification Center alerts by default for supported events. The CLI packages a signed notification helper and a Codex icon, so you’ll see trusted banners without needing to install `terminal-notifier`. If you prefer to take full control, or are on another platform, you can still wire up your own script via the `notify` setting described below. Providing a `notify` command overrides the built-in macOS integration. +The macOS build installs a background agent bundle at `~/.codex/bin/CodexNotifier.app`. Codex writes the notification payload to a temporary JSON file and launches the agent via `open -n ... --args --payload-file`. The agent removes the file after reading it. Set `CODEX_NOTIFIER_APP` to point at a different bundle if you maintain your own build. + Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.: ```json